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:
@@ -0,0 +1,893 @@
|
||||
# Copyright (c) 2025 Deep Robotics
|
||||
# SPDX-License-Identifier: BSD 3-Clause
|
||||
|
||||
"""评价套件的命令行入口。
|
||||
|
||||
与 ``eval.py`` 并存、互不影响:本脚本不导入也不修改 ``eval.py``,两者可以并行使用,
|
||||
输出 JSON 结构相似(指标名完全一致),便于互相对照。
|
||||
|
||||
用法示例::
|
||||
|
||||
# 标准评估(默认协议与火蚁描述)
|
||||
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
|
||||
|
||||
# 抗扰评估
|
||||
python scripts/reinforcement_learning/rsl_rl/eval_suite.py \\
|
||||
--task Rough-Deeprobotics-M20-v0 \\
|
||||
--checkpoint /绝对路径/model_19999.pt \\
|
||||
--eval-type robustness --headless
|
||||
|
||||
# 用 WebRTC 看画面(不需要显示器;局域网内用 Isaac Sim WebRTC Streaming Client 连)
|
||||
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 \\
|
||||
--livestream 2
|
||||
|
||||
``--livestream 1`` 走公网(NVIDIA NVCF 信令,需要 ``PUBLIC_IP``),
|
||||
``--livestream 2`` 走局域网直连;两者都由 ``AppLauncher`` 提供,详见
|
||||
``isaaclab.app.AppLauncher`` 的文档。本脚本只额外保证:视口相机跟着被评估的
|
||||
机器人(``--view-env``)、开始 rollout 前留出连接时间(``--pause-s``)、
|
||||
以及把「客户端该连哪里」打印出来。
|
||||
|
||||
协议预设与机器人描述都在 ``eval_kit/`` 里:改口径改 ``protocols.py``,
|
||||
加机器人改 ``profiles.py``。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import copy
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
#: 原始命令行,用于在需要多个环境时 fork 子进程(见文件末尾的说明)。
|
||||
_ORIGINAL_ARGV = list(sys.argv[1:])
|
||||
|
||||
# --- 路径引导:必须在导入 eval_kit / rl_training 之前完成 --------------------------
|
||||
_HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
PROJECT_ROOT = os.path.abspath(os.path.join(_HERE, "..", "..", ".."))
|
||||
PROJECT_SOURCE_PATH = os.path.join(PROJECT_ROOT, "source", "rl_training")
|
||||
for path in (PROJECT_SOURCE_PATH, PROJECT_ROOT):
|
||||
if path not in sys.path:
|
||||
sys.path.insert(0, path)
|
||||
|
||||
from isaaclab.app import AppLauncher # noqa: E402
|
||||
|
||||
from eval_kit.core.report import ( # noqa: E402
|
||||
EVAL_TYPES,
|
||||
filter_metrics,
|
||||
format_aggregate_summary,
|
||||
format_metric_result,
|
||||
format_rollout_summary,
|
||||
format_upper_limit_summary,
|
||||
)
|
||||
from eval_kit.core.schema import EvalProtocol # noqa: E402
|
||||
from eval_kit.core.terrain_spec import get_terrain_spec # noqa: E402
|
||||
from eval_kit.profiles import PROFILES, get_profile # noqa: E402
|
||||
from eval_kit.protocols import PROTOCOLS, get_protocol # noqa: E402
|
||||
|
||||
|
||||
def _build_parser() -> argparse.ArgumentParser:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="基于 eval_kit 的机器人性能评估(与 eval.py 并存,指标口径一致)。",
|
||||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||||
)
|
||||
parser.add_argument("--task", type=str, default="Rough-Deeprobotics-M20-v0", help="任务名。")
|
||||
parser.add_argument(
|
||||
"--agent", type=str, default="rsl_rl_cfg_entry_point", help="agent 配置入口名(配合 Hydra)。"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--profile", type=str, default="solenopsis", choices=sorted(PROFILES), help="机器人形态描述。"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--protocol", type=str, default="standard_v1", choices=sorted(PROTOCOLS), help="评估协议预设。"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--eval-type", dest="eval_type", type=str, default="all", choices=list(EVAL_TYPES), help="评估类型。"
|
||||
)
|
||||
|
||||
checkpoint = parser.add_argument_group("checkpoint")
|
||||
checkpoint.add_argument("--checkpoint", type=str, default=None, help="checkpoint 路径(支持绝对路径)。")
|
||||
checkpoint.add_argument("--load-run", dest="load_run", type=str, default=None, help="运行目录或目录名。")
|
||||
checkpoint.add_argument(
|
||||
"--experiment-name", dest="experiment_name", type=str, default=None,
|
||||
help="实验名,用于在 logs/rsl_rl/<实验名> 下查找;不传则用 agent 配置里的。",
|
||||
)
|
||||
|
||||
run = parser.add_argument_group("运行规模(不传则取协议预设值)")
|
||||
run.add_argument(
|
||||
"--num-envs", dest="num_envs", type=int, default=250,
|
||||
help="并行环境数(上限评估时会按网格规模自动覆盖)。",
|
||||
)
|
||||
# 注意:--device 由 AppLauncher.add_app_launcher_args() 提供,这里不能再加。
|
||||
# 它保留了 device / headless / enable_cameras / experience / livestream /
|
||||
# rendering_mode / xr 这几个名字,重复定义会直接抛 ValueError。
|
||||
run.add_argument(
|
||||
"--seeds", type=int, nargs="+", default=None,
|
||||
help="重复试验的随机种子,覆盖协议预设;给多个种子时会输出 mean±std。",
|
||||
)
|
||||
run.add_argument("--save-trace", dest="save_trace", type=str, default=None, help="把轨迹存到该目录。")
|
||||
run.add_argument(
|
||||
"--view-env", dest="view_env", type=int, default=None,
|
||||
help="让视口相机跟随第 N 个环境的机器人(非 headless 或开启推流时有效)。"
|
||||
"不传时默认跟随 env 0。环境编号 0 起,"
|
||||
"编号与(地形, 难度)的对应关系会在启动时打印。",
|
||||
)
|
||||
run.add_argument(
|
||||
"--free-camera", dest="free_camera", action="store_true", default=False,
|
||||
help="不要让视口相机跟随任何机器人:起始摆在俯视全场的位置,之后在 WebRTC 客户端里"
|
||||
"自己移动(脚本与 IsaacLab 都不会再改写相机)。同时给了 --view-env 时以本参数为准。",
|
||||
)
|
||||
run.add_argument(
|
||||
"--pause-s", dest="pause_s", type=float, default=None,
|
||||
help="开始 rollout 之前停顿的秒数:期间画面照常推流、物理冻结,按回车可提前开始。"
|
||||
"用来给 WebRTC 客户端留连接时间。开启推流时默认 30 s,其余情况默认 0(不停顿)。",
|
||||
)
|
||||
run.add_argument(
|
||||
"--record-frames", dest="record_frames", type=str, default=None,
|
||||
help="把 rollout 期间的视口画面按帧存成 PNG 到该目录(例如 frame_000150.png)。"
|
||||
"推流传输不可用时用它离线看评估过程;抓帧间隔见 --record-every。",
|
||||
)
|
||||
run.add_argument(
|
||||
"--record-every", dest="record_every", type=int, default=5,
|
||||
help="--record-frames 每多少步抓一帧。",
|
||||
)
|
||||
|
||||
overrides = parser.add_argument_group("口径覆盖(不传则取协议预设值,改动会反映到 fingerprint)")
|
||||
overrides.add_argument("--episode-length-s", dest="episode_length_s", type=float, default=None)
|
||||
overrides.add_argument("--command-x", dest="command_x", type=float, default=None)
|
||||
overrides.add_argument("--command-y", dest="command_y", type=float, default=None)
|
||||
overrides.add_argument("--command-yaw", dest="command_yaw", type=float, default=None)
|
||||
overrides.add_argument("--start-x", dest="start_x", type=float, default=None)
|
||||
overrides.add_argument("--start-y", dest="start_y", type=float, default=None)
|
||||
overrides.add_argument(
|
||||
"--start-z-offset", dest="start_z_offset", type=float, default=None,
|
||||
help="起始高度相对地形瓦片中心的抬升 [m]。倒金字塔台阶这类「中心最低」的地形需要正值,"
|
||||
"否则机器人会被生成在地形内部(默认 0.0,与旧 eval.py 一致)。",
|
||||
)
|
||||
overrides.add_argument("--terrain-size", dest="terrain_size", type=float, default=None, help="地形瓦片边长 [m]。")
|
||||
overrides.add_argument("--success-distance", dest="success_distance", type=float, default=None)
|
||||
overrides.add_argument("--tilt-threshold", dest="tilt_threshold", type=float, default=None, help="摔倒倾角 [rad]。")
|
||||
overrides.add_argument(
|
||||
"--acc-threshold-g", dest="acc_threshold_g", type=float, default=None,
|
||||
help="不稳定判定的加速度阈值,单位 g。",
|
||||
)
|
||||
overrides.add_argument(
|
||||
"--energy-convention", dest="energy_convention", type=str, default=None, choices=["absolute", "signed"]
|
||||
)
|
||||
overrides.add_argument(
|
||||
"--robot-mass", dest="robot_mass", type=float, default=None,
|
||||
help="覆盖 COT 用质量;默认从资产自动读取。",
|
||||
)
|
||||
overrides.add_argument(
|
||||
"--terrain-type", dest="terrain_type", type=str, default="all",
|
||||
help="上限评估只用某一种地形:all 或地形规格里的类型名。",
|
||||
)
|
||||
overrides.add_argument("--num-levels", dest="num_levels", type=int, default=None, help="上限评估的难度等级数。")
|
||||
overrides.add_argument("--envs-per-cell", dest="envs_per_cell", type=int, default=None, help="每格机器人数。")
|
||||
overrides.add_argument(
|
||||
"--no-terrain-bounds", dest="enforce_terrain_bounds", action="store_false", default=None,
|
||||
help="去掉越界终止项。地砖小于 3.8 m 时它会误判最外侧格子(详见 --help 里 protocol 的说明)。",
|
||||
)
|
||||
|
||||
output = parser.add_argument_group("输出")
|
||||
output.add_argument("--output", type=str, default=None, help="输出 JSON 路径;默认写在 checkpoint 同目录的 eval_kit/ 下。")
|
||||
output.add_argument("--print-json", dest="print_json", action="store_true", default=False, help="把 JSON 打到 stdout。")
|
||||
# 不能叫 --verbose:AppLauncher.add_app_launcher_args() 已经占用了这个名字,
|
||||
# 重复定义会让 argparse 直接抛 ArgumentError(旧 eval.py 就卡在这里)。
|
||||
output.add_argument(
|
||||
"--verbose-terrain", dest="verbose_terrain", action="store_true", default=False,
|
||||
help="打印逐格地形/难度调试信息。(注意 --verbose 属于 AppLauncher,行为不同)",
|
||||
)
|
||||
output.add_argument(
|
||||
"--include-summary", dest="include_summary", action="store_true", default=False,
|
||||
help="eval-type=all 时额外跑一次混合地形汇总。",
|
||||
)
|
||||
# 内部参数:一个进程只能建一个环境,需要多个环境时由父进程 fork 自己并传这些参数。
|
||||
# 不建议手工使用。
|
||||
internal = parser.add_argument_group("内部(子进程用,勿手工指定)")
|
||||
internal.add_argument("--stage", type=str, default="", choices=["", "upper", "plain"])
|
||||
internal.add_argument("--stage-seed", dest="stage_seed", type=int, default=0)
|
||||
internal.add_argument("--stage-out", dest="stage_out", type=str, default=None)
|
||||
return parser
|
||||
|
||||
|
||||
def _apply_overrides(protocol: EvalProtocol, args: argparse.Namespace) -> EvalProtocol:
|
||||
"""把命令行显式给出的口径覆盖到协议预设上。没给的不动,保持预设原值。"""
|
||||
updates: dict[str, object] = {}
|
||||
if args.episode_length_s is not None:
|
||||
updates["steps"] = int(round(args.episode_length_s / protocol.dt))
|
||||
if args.command_x is not None:
|
||||
updates["command"] = (args.command_x, protocol.command[1], protocol.command[2])
|
||||
if args.command_y is not None:
|
||||
current = updates.get("command", protocol.command)
|
||||
updates["command"] = (current[0], args.command_y, current[2])
|
||||
if args.command_yaw is not None:
|
||||
current = updates.get("command", protocol.command)
|
||||
updates["command"] = (current[0], current[1], args.command_yaw)
|
||||
if args.start_x is not None:
|
||||
updates["start_pose"] = (args.start_x, protocol.start_pose[1], protocol.start_pose[2])
|
||||
if args.start_y is not None:
|
||||
current = updates.get("start_pose", protocol.start_pose)
|
||||
updates["start_pose"] = (current[0], args.start_y, current[2])
|
||||
if args.start_z_offset is not None:
|
||||
updates["start_z_offset_m"] = args.start_z_offset
|
||||
if args.terrain_size is not None:
|
||||
updates["terrain_size_m"] = args.terrain_size
|
||||
if args.success_distance is not None:
|
||||
updates["success_distance_m"] = args.success_distance
|
||||
if args.tilt_threshold is not None:
|
||||
updates["tilt_threshold_rad"] = args.tilt_threshold
|
||||
if args.acc_threshold_g is not None:
|
||||
updates["acc_threshold_g"] = args.acc_threshold_g
|
||||
if args.energy_convention is not None:
|
||||
updates["energy_convention"] = args.energy_convention
|
||||
if args.num_levels is not None:
|
||||
updates["num_levels"] = args.num_levels
|
||||
if args.envs_per_cell is not None:
|
||||
updates["envs_per_cell"] = args.envs_per_cell
|
||||
if args.seeds is not None:
|
||||
updates["seeds"] = tuple(args.seeds)
|
||||
if args.enforce_terrain_bounds is not None:
|
||||
updates["enforce_terrain_bounds"] = args.enforce_terrain_bounds
|
||||
return replace(protocol, **updates) # type: ignore[arg-type]
|
||||
|
||||
|
||||
def _terrain_types_for(args: argparse.Namespace, spec) -> list[str]:
|
||||
if args.terrain_type == "all":
|
||||
return list(spec.types)
|
||||
if args.terrain_type not in spec.types:
|
||||
raise SystemExit(
|
||||
f"地形类型 {args.terrain_type!r} 不在规格 {spec.name!r} 里;可选:{list(spec.types)}"
|
||||
)
|
||||
return [args.terrain_type]
|
||||
|
||||
|
||||
def _resolve_output_path(args: argparse.Namespace, resume_path: str, eval_type: str) -> str:
|
||||
if args.output is not None:
|
||||
path = os.path.abspath(os.path.expanduser(args.output))
|
||||
else:
|
||||
from datetime import datetime
|
||||
|
||||
directory = os.path.join(os.path.dirname(resume_path), "eval_kit")
|
||||
suffix = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
||||
path = os.path.join(directory, f"{eval_type}_{suffix}.json")
|
||||
parent = os.path.dirname(path)
|
||||
if parent:
|
||||
os.makedirs(parent, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def _livestream_value(args_cli: argparse.Namespace) -> int:
|
||||
"""解析实际生效的推流模式:0 关、1 公网 WebRTC、2 局域网 WebRTC。
|
||||
|
||||
``--livestream`` 的默认值是 -1,含义是「听 ``LIVESTREAM`` 环境变量的」,
|
||||
所以 -1 时要回退去读环境变量,而不是当成 0。
|
||||
"""
|
||||
try:
|
||||
value = int(getattr(args_cli, "livestream", -1))
|
||||
except (TypeError, ValueError):
|
||||
value = -1
|
||||
if value < 0:
|
||||
try:
|
||||
value = int(os.environ.get("LIVESTREAM", 0) or 0)
|
||||
except ValueError:
|
||||
value = 0
|
||||
return value
|
||||
|
||||
|
||||
def _display_enabled(args_cli: argparse.Namespace) -> bool:
|
||||
"""这次运行会不会给用户看画面(本地 GUI 或推流)。"""
|
||||
return not getattr(args_cli, "headless", True) or _livestream_value(args_cli) > 0
|
||||
|
||||
|
||||
def _viewer_env_index(args_cli: argparse.Namespace) -> int | None:
|
||||
"""视口相机跟随哪个环境;不跟随时(自由视角、或完全不需要画面)返回 None。
|
||||
|
||||
必须在构造 ``AppLauncher`` **之前**调用:``AppLauncher`` 会把 ``livestream``
|
||||
从 namespace 里 pop 掉,还会在开启推流时把 ``headless`` 改写成 True(推流在
|
||||
服务端本来就按 headless 启动),等它跑完再读这两个值就分不清「用户要不要看画面」了。
|
||||
|
||||
推流时 ``SimulationContext`` 的 ``has_gui()``(本地 GUI 或推流 GUI)仍为真、
|
||||
``env_cfg.viewer`` 也仍然生效,所以推流和本地 GUI 两种情况都要设视口相机,
|
||||
否则推流画面停在默认相机上、看不到机器人。
|
||||
"""
|
||||
if not _display_enabled(args_cli) or getattr(args_cli, "free_camera", False):
|
||||
return None
|
||||
return 0 if args_cli.view_env is None else int(args_cli.view_env)
|
||||
|
||||
|
||||
def _set_free_camera_overview(env: Any) -> None:
|
||||
"""自由视角模式:把相机摆到能看见整片地形的位置。
|
||||
|
||||
``env_cfg.viewer.origin_type`` 是 ``world`` 时,``ViewportCameraController``
|
||||
只在初始化时设一次相机、之后不再改写,所以这里设的视角会一直保留,
|
||||
客户端里可以随便移动鼠标/键盘来观察。
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
origins = env.unwrapped.scene.env_origins
|
||||
center = origins.mean(dim=0)
|
||||
radius = float((origins - center).norm(dim=1).max().item())
|
||||
center_np = center.detach().cpu().numpy()
|
||||
eye = center_np + np.array([0.0, -1.6 * radius, 1.25 * radius], dtype=center_np.dtype)
|
||||
env.unwrapped.sim.set_camera_view(eye=eye, target=center_np)
|
||||
print(
|
||||
f"[eval_kit] 自由视角:相机不跟随机器人,起始俯视全场"
|
||||
f"(地形中心 {np.round(center_np, 1).tolist()},半径 {radius:.1f} m),"
|
||||
f"之后在客户端里自己移动",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def _allow_client_resize(args_cli: argparse.Namespace) -> None:
|
||||
"""让 WebRTC 客户端可以改画面尺寸(默认关掉会冻住画面)。
|
||||
|
||||
客户端一旦改窗口大小/全屏,就会发 ``onCustomResizeMessage``;服务端默认拒绝
|
||||
(``... must both be positive and /app/livestream/allowDynamicResize enabled``),
|
||||
同时推流用的 ``shared swapchain buffer`` 进入坏状态 —— 之后画面不再更新,而仿真
|
||||
照常在跑(终端里的 rollout 进度还在涨)。这两个设置通过 ``--kit_args`` 传给 Kit。
|
||||
|
||||
用户自己显式给了同名设置就不覆盖(想关掉就传 ``--kit_args=--/app/livestream/allowDynamicResize=false``)。
|
||||
"""
|
||||
wanted = ("--/app/livestream/allowResize=true", "--/app/livestream/allowDynamicResize=true")
|
||||
existing = args_cli.kit_args or ""
|
||||
extra = [flag for flag in wanted if flag.split("=")[0] not in existing]
|
||||
if not extra:
|
||||
return
|
||||
args_cli.kit_args = " ".join(filter(None, [existing, *extra]))
|
||||
print(
|
||||
f"[eval_kit] 推流追加 kit 设置:{' '.join(extra)}(允许客户端调整窗口大小/全屏)",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def _grab_viewport_frame(state: dict, directory: str, every: int) -> None:
|
||||
"""每 ``every`` 步向视口要一张 PNG。
|
||||
|
||||
抓帧是异步的(等下一次渲染产出),所以这里不阻塞 rollout:上一张还没写完就跳过
|
||||
这一帧。写完的文件名形如 ``frame_000150.png``,可用 ffmpeg 合成视频。
|
||||
"""
|
||||
if every <= 0 or state["step"] % every:
|
||||
return
|
||||
pending = state["pending"]
|
||||
if pending is not None and not os.path.exists(pending):
|
||||
return
|
||||
from omni.kit.viewport.utility import capture_viewport_to_file, get_active_viewport
|
||||
|
||||
path = os.path.join(directory, f"frame_{state['step']:06d}.png")
|
||||
state["pending"] = path
|
||||
capture_viewport_to_file(get_active_viewport(), path)
|
||||
|
||||
|
||||
def _with_frame_capture(policy: Any, directory: str, every: int) -> Any:
|
||||
"""把策略包一层,顺带按间隔抓视口帧(策略每被调用一次就是 rollout 推进一步)。"""
|
||||
os.makedirs(directory, exist_ok=True)
|
||||
state: dict = {"step": 0, "pending": None}
|
||||
print(f"[eval_kit] rollout 期间抓帧到 {directory}(每 {every} 步一张 PNG)", flush=True)
|
||||
|
||||
def wrapped(obs: Any) -> Any:
|
||||
state["step"] += 1
|
||||
_grab_viewport_frame(state, directory, every)
|
||||
return policy(obs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def _report_recorded_frames(directory: str, every: int, dt: float) -> None:
|
||||
"""抓帧结束后提示怎么合成视频(帧是按仿真时间等间隔抓的,fps = 1/(every*dt))。"""
|
||||
frames = sorted(name for name in os.listdir(directory) if name.endswith(".png"))
|
||||
if not frames:
|
||||
print(f"[eval_kit] 抓帧目录 {directory} 里没有帧(视口没渲染?)", flush=True)
|
||||
return
|
||||
fps = 1.0 / (every * dt) if every > 0 and dt > 0 else 10.0
|
||||
print(f"[eval_kit] 抓到 {len(frames)} 帧:{directory}", flush=True)
|
||||
print(
|
||||
f"[eval_kit] 合成视频(按仿真速度播放):cd {directory} && "
|
||||
f"ffmpeg -y -framerate {fps:.2f} -pattern_type glob -i '*.png' "
|
||||
f"-pix_fmt yuv420p -c:v libx264 -vf 'pad=ceil(iw/2)*2:ceil(ih/2)*2' /tmp/eval.mp4",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def _local_ipv4_addresses() -> list[str]:
|
||||
"""本机的非回环 IPv4 地址,用来告诉用户 WebRTC 客户端该连哪里。"""
|
||||
import socket
|
||||
|
||||
addresses: list[str] = []
|
||||
try:
|
||||
for info in socket.getaddrinfo(socket.gethostname(), None, socket.AF_INET):
|
||||
address = info[4][0]
|
||||
if address not in addresses:
|
||||
addresses.append(address)
|
||||
except OSError:
|
||||
pass
|
||||
try:
|
||||
# 不真的发包:只是让内核按路由表挑一个默认出口地址
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as probe:
|
||||
probe.connect(("8.8.8.8", 80))
|
||||
address = probe.getsockname()[0]
|
||||
if address not in addresses:
|
||||
addresses.append(address)
|
||||
except OSError:
|
||||
pass
|
||||
return [address for address in addresses if not address.startswith("127.")]
|
||||
|
||||
|
||||
def _print_livestream_info(livestream: int) -> None:
|
||||
"""打印客户端该连到哪里。要在 Kit 起来之后调用,端口从 carb 设置里读实际值。"""
|
||||
import carb.settings
|
||||
|
||||
port = carb.settings.get_settings().get("/app/livestream/port")
|
||||
addresses = _local_ipv4_addresses()
|
||||
if livestream == 1:
|
||||
public_ip = os.environ.get("PUBLIC_IP") or (addresses[0] if addresses else "127.0.0.1")
|
||||
print(f"[eval_kit] WebRTC 推流已启用(--livestream 1,公网,PUBLIC_IP={public_ip})", flush=True)
|
||||
else:
|
||||
print("[eval_kit] WebRTC 推流已启用(--livestream 2,局域网直连)", flush=True)
|
||||
if addresses:
|
||||
targets = " ".join(f"{address}:{port}" for address in addresses)
|
||||
print(f"[eval_kit] 客户端连接地址:{targets}", flush=True)
|
||||
else:
|
||||
print(f"[eval_kit] 客户端连接地址:<本机 IP>:{port}", flush=True)
|
||||
print(
|
||||
"[eval_kit] 本子进程结束后推流会断开(每个阶段/种子都是一个新进程),"
|
||||
"客户端重连即可",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def _hold_before_rollout(sim: Any, seconds: float, *, start_height_note: str) -> None:
|
||||
"""停顿 ``seconds`` 秒再开始 rollout:画面继续推流、物理冻结。
|
||||
|
||||
用 ``sim.render()`` 而不是 ``time.sleep``:独立进程模式下 Kit 的更新循环由我们
|
||||
调用 ``app.update()`` 驱动,光 sleep 推流画面会冻住。``render()`` 内部会先把
|
||||
``/app/player/playSimulations`` 关掉再 update,因此画面在刷新、时间不前进。
|
||||
另外把 timeline 暂停,避免物理在等待期间自己走掉(那会让评估起点不是刚重置的状态)。
|
||||
"""
|
||||
import select
|
||||
import time as _time
|
||||
|
||||
sim.pause()
|
||||
started = _time.monotonic()
|
||||
deadline = started + seconds
|
||||
# 明确说明「悬空不动」是正常的:这一窗物理是冻结的,机器人正停在法规的起始位姿上
|
||||
print(
|
||||
f"[eval_kit] 画面已就绪,先停 {seconds:.0f} s 再开始 rollout —— 期间物理是冻结的,"
|
||||
f"机器人在起始位姿上不动{start_height_note},看起来「悬空不动」是正常的。",
|
||||
flush=True,
|
||||
)
|
||||
if sys.stdin is not None and sys.stdin.isatty():
|
||||
print("[eval_kit] 在终端按回车可立即开始 rollout", flush=True)
|
||||
next_report = started + 5.0
|
||||
while _time.monotonic() < deadline:
|
||||
sim.render()
|
||||
if sys.stdin is not None and sys.stdin.isatty() and select.select([sys.stdin], [], [], 0)[0]:
|
||||
sys.stdin.readline()
|
||||
break
|
||||
if _time.monotonic() >= next_report:
|
||||
print(f"[eval_kit] 剩余 {max(0.0, deadline - _time.monotonic()):.0f} s(物理仍冻结)", flush=True)
|
||||
next_report += 5.0
|
||||
sim.play()
|
||||
print(f"[eval_kit] 停顿 {_time.monotonic() - started:.1f} s 结束,开始 rollout(画面从这里才动)", flush=True)
|
||||
|
||||
|
||||
def _format_early_reset_warning(early: dict, protocol: EvalProtocol, min_terrain_size: float) -> str:
|
||||
"""把早退统计变成可执行的建议。"""
|
||||
import json as _json
|
||||
|
||||
lines = [
|
||||
"[警告] 有环境在 episode 中途被重置,路程/进度类指标可能被污染:"
|
||||
f"{_json.dumps(early, ensure_ascii=False)}"
|
||||
]
|
||||
if protocol.enforce_terrain_bounds:
|
||||
lines.append(" 最常见的原因是 IsaacLab 的 terrain_out_of_bounds 终止项:它比较机器人的")
|
||||
lines.append(" 世界坐标与整张地图的范围,而不是机器人自己那块地砖,于是最外侧的格子")
|
||||
lines.append(f" 从第 0 步起就被判越界。当前地砖 {protocol.terrain_size_m} m,")
|
||||
lines.append(f" 要保留该终止项需要 >= {min_terrain_size:.1f} m。三种处置:")
|
||||
lines.append(f" 1) --terrain-size {max(min_terrain_size, protocol.terrain_size_m):.1f} 保留终止项,消除误判")
|
||||
lines.append(" 2) --no-terrain-bounds 去掉该终止项")
|
||||
lines.append(" 3) --protocol standard_v1_clean 等价于方案 1")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _stage_argv(stage: str, seed: int, out_path: str) -> list[str]:
|
||||
"""构造子进程命令行:在原始参数上追加阶段参数。"""
|
||||
return [
|
||||
sys.executable,
|
||||
os.path.abspath(__file__),
|
||||
*_ORIGINAL_ARGV,
|
||||
"--stage", stage,
|
||||
"--stage-seed", str(seed),
|
||||
"--stage-out", out_path,
|
||||
]
|
||||
|
||||
|
||||
def _spawn_stage(stage: str, seed: int) -> dict[str, object]:
|
||||
"""在子进程里跑一个「一个环境」的阶段,读回它写的 JSON。
|
||||
|
||||
为什么必须开子进程:IsaacLab 在本配置下一个进程只能创建一个环境。第一个环境
|
||||
建好之后,``ROUGH_TERRAINS_CFG`` 这类模块级单例会被运行时长出自引用,之后
|
||||
任何 cfg 构造(新实例 ``env_cfg_cls()``、开场存的 pristine 深拷贝、重新深拷贝)
|
||||
都会在 ``cfg.validate()`` 里 RecursionError。三种补救方式都实测失败,所以只能
|
||||
一个环境一个进程。
|
||||
"""
|
||||
handle = tempfile.NamedTemporaryFile(prefix=f"eval_kit_{stage}_", suffix=".json", delete=False)
|
||||
handle.close()
|
||||
out_path = handle.name
|
||||
argv = _stage_argv(stage, seed, out_path)
|
||||
print(f"[eval_kit] 子进程 stage={stage} seed={seed}", flush=True)
|
||||
completed = subprocess.run(argv)
|
||||
if completed.returncode != 0:
|
||||
os.unlink(out_path)
|
||||
raise SystemExit(f"子进程 stage={stage} seed={seed} 失败(退出码 {completed.returncode})")
|
||||
try:
|
||||
with open(out_path, encoding="utf-8") as file_handle:
|
||||
return json.load(file_handle)
|
||||
finally:
|
||||
os.unlink(out_path)
|
||||
|
||||
|
||||
def _recorder_report(recorder) -> dict[str, object]:
|
||||
return {
|
||||
"feet": recorder.feet_names if recorder else [],
|
||||
"support": recorder.support_names if recorder else [],
|
||||
"joints": recorder.joint_names if recorder else [],
|
||||
"mass_kg": recorder.mass_kg if recorder else None,
|
||||
"warnings": recorder.warnings if recorder else [],
|
||||
}
|
||||
|
||||
|
||||
def _run_parent(args_cli: argparse.Namespace) -> None:
|
||||
"""父进程:不启动仿真,只做编排与合并。每个环境交给一个子进程。"""
|
||||
import numpy as np
|
||||
|
||||
from eval_kit.core.aggregation import aggregate_runs, build_upper_limit_matrix
|
||||
from eval_kit.core.report import filter_metrics, format_aggregate_summary, format_metric_result
|
||||
from eval_kit.core.report import format_rollout_summary, format_upper_limit_summary
|
||||
|
||||
profile = get_profile(args_cli.profile)
|
||||
protocol = _apply_overrides(get_protocol(args_cli.protocol), args_cli)
|
||||
spec = get_terrain_spec(protocol.terrain_spec)
|
||||
if args_cli.robot_mass is not None:
|
||||
profile = replace(profile, mass_kg=args_cli.robot_mass)
|
||||
|
||||
if not args_cli.checkpoint:
|
||||
raise SystemExit(
|
||||
"编排进程需要 --checkpoint 才能派发子进程(父进程不加载 hydra,"
|
||||
"因此无法用 --load-run 按实验名查找)。请传入 checkpoint 路径。"
|
||||
)
|
||||
resume_path = os.path.abspath(os.path.expanduser(args_cli.checkpoint))
|
||||
if not os.path.isfile(resume_path):
|
||||
raise SystemExit(f"找不到 checkpoint:{resume_path}")
|
||||
|
||||
print("[eval_kit] 协议预设:", args_cli.protocol)
|
||||
print("[eval_kit] profile :", profile.name)
|
||||
print("[eval_kit] checkpoint:", resume_path)
|
||||
print("[eval_kit] 口径 :", json.dumps(protocol.to_dict(), ensure_ascii=False))
|
||||
print("[eval_kit] 地形规格 :", spec.to_dict())
|
||||
print("[eval_kit] 种子 :", list(protocol.seeds), "(每个种子一个子进程)")
|
||||
|
||||
result: dict[str, object] = {
|
||||
"suite": "eval_kit",
|
||||
"task": args_cli.task,
|
||||
"eval_type": args_cli.eval_type,
|
||||
"checkpoint": resume_path,
|
||||
"profile": profile.to_dict(),
|
||||
"protocol": protocol.to_dict(),
|
||||
"terrain_spec": spec.to_dict(),
|
||||
"seeds": list(protocol.seeds),
|
||||
"runner": "one-env-per-process",
|
||||
}
|
||||
|
||||
wants_upper = args_cli.eval_type in ("upper_limits", "all")
|
||||
|
||||
if wants_upper:
|
||||
terrain_types = _terrain_types_for(args_cli, spec)
|
||||
per_seed: list[dict[str, object]] = []
|
||||
pooled: dict[str, list[float]] = {"success": [], "max_progress": [], "max_tilt": []}
|
||||
pooled_levels: list[np.ndarray] = []
|
||||
pooled_types: list[np.ndarray] = []
|
||||
recorder_report: dict[str, object] = {}
|
||||
|
||||
for seed in protocol.seeds:
|
||||
data = _spawn_stage("upper", seed)
|
||||
recorder_report = data["recorder"] # type: ignore[assignment]
|
||||
per_env = data["per_env"] # type: ignore[assignment]
|
||||
level_ids = np.asarray(data["level_ids"]) # type: ignore[arg-type]
|
||||
type_ids = np.asarray(data["type_ids"]) # type: ignore[arg-type]
|
||||
for key in pooled:
|
||||
pooled[key].extend(per_env[key]) # type: ignore[index]
|
||||
pooled_levels.append(level_ids)
|
||||
pooled_types.append(type_ids)
|
||||
matrix = build_upper_limit_matrix(
|
||||
per_env, level_ids, type_ids, terrain_types, spec, protocol.num_levels # type: ignore[arg-type]
|
||||
)
|
||||
per_seed.append(
|
||||
{
|
||||
"seed": seed,
|
||||
"scores": matrix["scores"],
|
||||
"matrix": matrix["matrix"],
|
||||
"metrics": data["metrics"],
|
||||
"early_resets": data["early_resets"],
|
||||
}
|
||||
)
|
||||
if args_cli.verbose_terrain:
|
||||
print(f"[eval_kit] seed {seed}: " + " ".join(f"{k}={v:.2f}" for k, v in matrix["scores"].items()))
|
||||
|
||||
pooled_matrix = build_upper_limit_matrix(
|
||||
pooled, np.concatenate(pooled_levels), np.concatenate(pooled_types), terrain_types, spec, protocol.num_levels
|
||||
)
|
||||
result["terrain_types"] = terrain_types
|
||||
result["upper_limits"] = pooled_matrix
|
||||
result["upper_limits_per_seed"] = per_seed
|
||||
result["recorder"] = recorder_report
|
||||
print()
|
||||
print(format_upper_limit_summary(pooled_matrix))
|
||||
first_metrics = per_seed[0]["metrics"] # type: ignore[index]
|
||||
print()
|
||||
print(format_rollout_summary(first_metrics)) # type: ignore[arg-type]
|
||||
|
||||
for entry in per_seed:
|
||||
early = entry["early_resets"] # type: ignore[index]
|
||||
if early and early["polluted"]: # type: ignore[index]
|
||||
print()
|
||||
print(_format_early_reset_warning(early, protocol, protocol.minimum_terrain_size_for_bounds())) # type: ignore[index,arg-type]
|
||||
break
|
||||
|
||||
if args_cli.eval_type == "all" and args_cli.include_summary:
|
||||
runs = []
|
||||
for seed in protocol.seeds:
|
||||
data = _spawn_stage("plain", seed)
|
||||
runs.append((seed, data["metrics"])) # type: ignore[arg-type]
|
||||
result["summary"] = aggregate_runs(runs) # type: ignore[arg-type]
|
||||
print()
|
||||
print("[混合地形汇总]")
|
||||
print(format_aggregate_summary(result["summary"])) # type: ignore[arg-type]
|
||||
if runs:
|
||||
result["summary_metrics"] = filter_metrics(runs[0][1], args_cli.eval_type)
|
||||
else:
|
||||
runs = []
|
||||
recorder_report = {}
|
||||
early_any = None
|
||||
for seed in protocol.seeds:
|
||||
data = _spawn_stage("plain", seed)
|
||||
runs.append((seed, data["metrics"])) # type: ignore[arg-type]
|
||||
recorder_report = data["recorder"] # type: ignore[assignment]
|
||||
early_any = data["early_resets"]
|
||||
aggregate = aggregate_runs(runs) # type: ignore[arg-type]
|
||||
result["aggregate"] = aggregate
|
||||
result["metrics"] = filter_metrics(runs[0][1], args_cli.eval_type)
|
||||
result["recorder"] = recorder_report
|
||||
result["early_resets"] = early_any
|
||||
print()
|
||||
print(format_metric_result(args_cli.eval_type, runs[0][1]))
|
||||
if len(runs) > 1:
|
||||
print()
|
||||
print(format_aggregate_summary(aggregate))
|
||||
if early_any and early_any["polluted"]: # type: ignore[index]
|
||||
print()
|
||||
print(_format_early_reset_warning(early_any, protocol, protocol.minimum_terrain_size_for_bounds())) # type: ignore[index,arg-type]
|
||||
|
||||
warnings = result.get("recorder", {}).get("warnings") # type: ignore[union-attr]
|
||||
if warnings:
|
||||
print("\n[形态解析警告]")
|
||||
for warning in warnings: # type: ignore[union-attr]
|
||||
print(" -", warning)
|
||||
|
||||
output_path = _resolve_output_path(args_cli, resume_path, args_cli.eval_type)
|
||||
with open(output_path, "w", encoding="utf-8") as handle:
|
||||
json.dump(result, handle, indent=2, ensure_ascii=False)
|
||||
print(f"\n[eval_kit] 结果已保存:{output_path}")
|
||||
if args_cli.print_json:
|
||||
print(json.dumps(result, indent=2, ensure_ascii=False))
|
||||
|
||||
|
||||
def _run_child(
|
||||
args_cli: argparse.Namespace,
|
||||
*,
|
||||
viewer_env_index: int | None,
|
||||
free_camera: bool,
|
||||
livestream: int,
|
||||
pause_s: float,
|
||||
) -> None:
|
||||
"""子进程:启动仿真,只建一个环境,跑完把结果写进 ``--stage-out``。
|
||||
|
||||
``viewer_env_index`` / ``free_camera`` / ``livestream`` / ``pause_s`` 都由
|
||||
:func:`main` 在 ``AppLauncher`` 之前算好传进来:构造 ``AppLauncher`` 会改写
|
||||
``headless`` 并把 ``livestream`` 从 namespace 里 pop 掉,之后再读就晚了。
|
||||
"""
|
||||
print(f"[eval_kit][child] stage={args_cli.stage} seed={args_cli.stage_seed} 开始", flush=True)
|
||||
import numpy as np
|
||||
|
||||
from isaaclab_rl.rsl_rl import RslRlOnPolicyRunnerCfg
|
||||
from isaaclab_tasks.utils.hydra import hydra_task_config
|
||||
|
||||
import rl_training # noqa: F401 触发任务注册
|
||||
import rl_training.tasks # noqa: F401
|
||||
|
||||
from eval_kit.core.aggregation import build_upper_limit_matrix
|
||||
from eval_kit.core.metrics import compute_metrics
|
||||
from eval_kit.isaaclab import env_setup
|
||||
from eval_kit.isaaclab.policy import load_policy, make_env, resolve_checkpoint
|
||||
from eval_kit.isaaclab.recorder import build_recorder, record_rollout, summarize_early_resets
|
||||
|
||||
profile = get_profile(args_cli.profile)
|
||||
# 子进程只跑一个种子:种子的覆盖由父进程通过 --stage-seed 传进来
|
||||
protocol = replace(_apply_overrides(get_protocol(args_cli.protocol), args_cli), seeds=(args_cli.stage_seed,))
|
||||
spec = get_terrain_spec(protocol.terrain_spec)
|
||||
if args_cli.robot_mass is not None:
|
||||
profile = replace(profile, mass_kg=args_cli.robot_mass)
|
||||
|
||||
@hydra_task_config(args_cli.task, args_cli.agent)
|
||||
def _child(env_cfg, agent_cfg: RslRlOnPolicyRunnerCfg):
|
||||
experiment_name = args_cli.experiment_name or agent_cfg.experiment_name
|
||||
log_root_path = os.path.abspath(os.path.join("logs", "rsl_rl", experiment_name))
|
||||
resume_path = resolve_checkpoint(log_root_path, args_cli.load_run, args_cli.checkpoint)
|
||||
|
||||
cfg = copy.deepcopy(env_cfg)
|
||||
if args_cli.stage == "upper":
|
||||
terrain_types = _terrain_types_for(args_cli, spec)
|
||||
env_setup.configure_upper_limit_env(
|
||||
cfg, spec, protocol, terrain_types,
|
||||
seed=args_cli.stage_seed, device=args_cli.device,
|
||||
viewer_env_index=viewer_env_index, free_camera=free_camera,
|
||||
)
|
||||
else:
|
||||
terrain_types = []
|
||||
env_setup.configure_eval_env(
|
||||
cfg, spec, protocol, num_envs=args_cli.num_envs,
|
||||
device=args_cli.device, seed=args_cli.stage_seed,
|
||||
viewer_env_index=viewer_env_index, free_camera=free_camera,
|
||||
)
|
||||
|
||||
if viewer_env_index is not None:
|
||||
num_envs = int(cfg.scene.num_envs)
|
||||
if not (0 <= viewer_env_index < num_envs):
|
||||
raise SystemExit(
|
||||
f"--view-env {viewer_env_index} 超出范围:本次共 {num_envs} 个环境(编号 0~{num_envs - 1})。"
|
||||
)
|
||||
if terrain_types:
|
||||
per_type = protocol.num_levels * protocol.envs_per_cell
|
||||
type_index, remainder = divmod(viewer_env_index, per_type)
|
||||
level_index = remainder // protocol.envs_per_cell
|
||||
print(
|
||||
f"[eval_kit] 视口跟随 env {viewer_env_index} → 地形 {terrain_types[type_index]}"
|
||||
f",难度等级 L{level_index + 1}(共 {num_envs} 个环境)",
|
||||
flush=True,
|
||||
)
|
||||
else:
|
||||
print(f"[eval_kit] 视口跟随 env {viewer_env_index}(共 {num_envs} 个环境)", flush=True)
|
||||
|
||||
env = make_env(args_cli.task, cfg, agent_cfg.clip_actions)
|
||||
recorder = build_recorder(env, profile)
|
||||
try:
|
||||
# 策略跟仿真放在同一张卡上:默认的 --device 也会覆盖 agent 配置里的
|
||||
# device,否则在别人占着 cuda:0 的机器上会莫名其妙 OOM。
|
||||
policy = load_policy(env, agent_cfg, resume_path, device=args_cli.device)
|
||||
if args_cli.record_frames:
|
||||
policy = _with_frame_capture(policy, args_cli.record_frames, int(args_cli.record_every))
|
||||
if args_cli.stage == "upper":
|
||||
level_ids, type_ids = env_setup.assign_upper_limit_origins(env, spec, protocol, terrain_types)
|
||||
if free_camera:
|
||||
_set_free_camera_overview(env)
|
||||
if pause_s > 0:
|
||||
_hold_before_rollout(
|
||||
env.unwrapped.sim,
|
||||
pause_s,
|
||||
start_height_note=(
|
||||
f"(本次起始高度被抬高了 {protocol.start_z_offset_m:.2f} m,"
|
||||
f"所以是离地悬着的)"
|
||||
if protocol.start_z_offset_m > 0
|
||||
else ""
|
||||
),
|
||||
)
|
||||
trace = record_rollout(env, policy, protocol, profile, recorder)
|
||||
if args_cli.record_frames:
|
||||
_report_recorded_frames(args_cli.record_frames, int(args_cli.record_every), protocol.dt)
|
||||
metrics = compute_metrics(
|
||||
trace, profile, protocol, include_per_env=(args_cli.stage == "upper")
|
||||
)
|
||||
early = summarize_early_resets(trace)
|
||||
finally:
|
||||
env.close()
|
||||
|
||||
if args_cli.save_trace:
|
||||
os.makedirs(args_cli.save_trace, exist_ok=True)
|
||||
tag = "upper" if args_cli.stage == "upper" else args_cli.eval_type
|
||||
trace.save(os.path.join(args_cli.save_trace, f"{tag}_seed{args_cli.stage_seed}.npz"))
|
||||
|
||||
payload: dict[str, object] = {
|
||||
"seed": args_cli.stage_seed,
|
||||
"metrics": metrics,
|
||||
"early_resets": early,
|
||||
"recorder": _recorder_report(recorder),
|
||||
}
|
||||
if args_cli.stage == "upper":
|
||||
payload["per_env"] = metrics.pop("_per_env")
|
||||
payload["level_ids"] = level_ids.cpu().numpy().tolist()
|
||||
payload["type_ids"] = type_ids.cpu().numpy().tolist()
|
||||
with open(args_cli.stage_out, "w", encoding="utf-8") as handle:
|
||||
json.dump(payload, handle)
|
||||
|
||||
_child()
|
||||
print("[eval_kit][child] 完成", flush=True)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = _build_parser()
|
||||
AppLauncher.add_app_launcher_args(parser)
|
||||
args_cli, hydra_args = parser.parse_known_args()
|
||||
|
||||
# 父进程只做编排:它不建环境,因此也不需要启动仿真。
|
||||
if args_cli.stage == "":
|
||||
if _livestream_value(args_cli) > 0 and not args_cli.headless:
|
||||
print(
|
||||
"[eval_kit] 注意:--livestream 会自动进入 headless 模式(推流由服务端渲染),"
|
||||
"不需要也不应该再传 --headless。",
|
||||
flush=True,
|
||||
)
|
||||
_run_parent(args_cli)
|
||||
return
|
||||
|
||||
# 这三个值必须在 AppLauncher 之前定下来(见 _viewer_env_index 的说明)。
|
||||
livestream = _livestream_value(args_cli)
|
||||
viewer_env_index = _viewer_env_index(args_cli)
|
||||
free_camera = bool(getattr(args_cli, "free_camera", False)) and _display_enabled(args_cli)
|
||||
pause_s = args_cli.pause_s if args_cli.pause_s is not None else (30.0 if livestream > 0 else 0.0)
|
||||
if args_cli.free_camera and not free_camera:
|
||||
print(
|
||||
"[eval_kit] 提示:--free-camera 只在有画面时(本地 GUI 或 --livestream)有效,"
|
||||
"本次是 headless 且未推流,忽略该参数。",
|
||||
flush=True,
|
||||
)
|
||||
if livestream > 0:
|
||||
_allow_client_resize(args_cli)
|
||||
|
||||
# 子进程:启动仿真,跑一个环境,然后退出。
|
||||
sys.argv = [sys.argv[0]] + hydra_args
|
||||
simulation_app = AppLauncher(args_cli).app
|
||||
try:
|
||||
if livestream > 0:
|
||||
_print_livestream_info(livestream)
|
||||
_run_child(
|
||||
args_cli,
|
||||
viewer_env_index=viewer_env_index,
|
||||
free_camera=free_camera,
|
||||
livestream=livestream,
|
||||
pause_s=pause_s,
|
||||
)
|
||||
except BaseException:
|
||||
# 关键:不能走 simulation_app.close()。它会调用 os._exit(0),把「子进程失败」
|
||||
# 伪装成「成功」,并且丢掉还没 flush 的 stderr —— 于是父进程只看到退出码 0
|
||||
# 和一个空的 JSON,真实报错(例如资产配置里的 SyntaxError)完全看不到。
|
||||
# 先把报错刷出去,再以非零码强制退出,父进程才会把真实错误报上来。
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
stream.flush()
|
||||
os._exit(1)
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
stream.flush()
|
||||
simulation_app.close()
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user