Skip to content

Commit 6b68f4a

Browse files
committed
Merge remote-tracking branch 'origin/main' into release/superdex-1.1.1
# Conflicts: # docs/sphinx/source/adr/ADR-0000-index.md # docs/sphinx/source/adr/README.md
2 parents 50a4353 + 45e4dff commit 6b68f4a

27 files changed

Lines changed: 1818 additions & 338 deletions

‎docs/sphinx/source/adr/ADR-0000-index.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ orphan: true
2121
| [ADR-0005 Unified Obs Critic Env And IPC Contract](ADR-0005-unified-obs-critic-env-and-ipc-contract.md) | Observation / IPC | Accepted |
2222
| [ADR-0006 Community Manager API On NumPy Runtime](ADR-0006-community-manager-api-on-numpy-runtime.md) | Manager API / NumPy runtime | Accepted |
2323
| [ADR-0007 UniSim Extraction Boundary](ADR-0007-unisim-extraction-boundary.md) | Physics package extraction | Accepted |
24-
| [ADR-0008 SuperDex Native C++ Scene Batch Executor](ADR-0008-superdex-persistent-cpu-workers.md) | Backend CPU scene execution | Accepted |
24+
| [ADR-0008 Debug Overlay Primitive Contract And Playback Session](ADR-0008-debug-overlay-primitive-contract-and-playback-session.md) | Debug overlay / playback session | Accepted |
25+
| [ADR-0009 SuperDex Native C++ Scene Batch Executor](ADR-0009-superdex-persistent-cpu-workers.md) | Backend CPU scene execution | Accepted |
2526

2627
## ADR Governance
2728

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
---
2+
orphan: true
3+
---
4+
5+
# ADR-0008 Debug Overlay Primitive Contract And Embeddable Playback Session
6+
7+
- Status: Accepted
8+
- Date: 2026-09-09
9+
- Owners: Backend / Visualization maintainers
10+
- Supersedes: None
11+
- Superseded by: None
12+
13+
## Context
14+
15+
播放/录制管线的任务侧叠加层过去依赖 `(num_envs, 3)` marker 位置数组
16+
(`extra_data_getter`),只能表达“每个 env 一个球”,无法表达坐标系、箭头、
17+
ghost mesh 或文本等任务 debug 语义,交互 viewer(`play_interactive.py`)与离线
18+
record 管线各自维护了一套互不兼容的绘制代码。
19+
20+
上游 unisim(unilabsim/unisim#53, 驱动 issue unilabsim/wuji_unilab#21)把
21+
`SimBackend.run_playback` 的 `extra_data_getter` 替换为
22+
`debug_overlay_getter`,引入 typed `DebugPrimitive`
23+
(sphere/box/frame/arrow/ghost_geom/text,env 局部系位姿)、typed `CameraCfg`
24+
(`from_kwargs` 归一化、未知键 fail-closed)以及
25+
`BackendPlayCapabilities.supports_debug_overlay`。UniLab 作为下游需要:
26+
27+
1. 把 env 契约(`ABEnv`/`NpEnv`)和所有调用点迁移到新契约;
28+
2. 收敛交互 viewer 的硬编码 `user_scn` 叠加层到同一原语契约;
29+
3. 把 record 管线的“快照缓存 + 事后渲染”暴露为可嵌入组件,供自定义 eval
30+
循环(自写 trial 协议)复用。
31+
32+
这跨越 env/backend 公共契约,普通实现说明不足以作为 review 基线。
33+
34+
## Decision
35+
36+
1. **叠加层一律使用 typed `DebugPrimitive`。** env 契约透传
37+
`debug_overlay_getter: Callable[[], Sequence[Sequence[DebugPrimitive] | None] | None]`,
38+
外层长度等于 `num_envs`,位姿为 env 局部系;grid offset 由渲染器应用。
39+
旧的 `extra_data_getter` marker 数组契约在 UniLab 侧零残留。
40+
2. **capability 门控 fail-closed。** `EnvPlayCapabilities` 增加
41+
`supports_debug_overlay` 并透传 backend capability;调用方在 backend 不
42+
支持时省略 getter,backend 在收到 getter 但不支持时抛
43+
`NotImplementedError`。
44+
3. **交互 viewer 与离线渲染共用原语数据层。** `play_interactive.py` 只构造
45+
`DebugPrimitive` 列表;注入 `viewer.user_scn` 的统一入口是
46+
`unilab.visualization.debug_primitives.append_debug_primitives_to_scene`。
47+
该注入器是 UniLab 持有的过渡实现——unisim 的离线 worker 绘制实现
48+
(`_append_primitive`/`_append_debug_primitives`)是私有的,且面向多 env
49+
网格合成而非交互路径(单 env、已加载 model、调用方持有 mjvScene);
50+
待 unisim 暴露适合交互路径的公开 helper 后改为委托(见 Consequences)。
51+
4. **record 管线组件化为 `SnapshotPlaybackSession`。**
52+
`unilab.visualization.playback_session.SnapshotPlaybackSession` 把
53+
`snapshot()`(每 trial 步缓存 physics state 与可选叠加层)与
54+
`render_snapshots(output_video=..., overlay_getter=..., camera=..., fps=...,
55+
on_frame=...)`(统一渲染出 mp4)暴露为 session 级公开操作,渲染走共享的
56+
MuJoCo 离线 snapshot 管线。session 只持有 NumPy 数组与 typed 原语,缓存
57+
可 pickle;trial 之间用 `clear()` 界定生命周期。
58+
5. **`ABEnv.render(mode="rgb_array")` 便捷封装。** 内部走
59+
`init_play_renderer(headless=True, capture=True)` +
60+
`capture_play_video_frame`,按 `supports_native_video_capture` 门控,不支持
61+
时抛带类名的 `NotImplementedError`。
62+
6. **camera 配置面统一过 `CameraCfg.from_kwargs`。** train/play 脚本通过
63+
`unilab.visualization.playback.camera_cfg_from_training` 组装 typed camera
64+
配置;Hydra YAML 字段名不变,未知键在边界 fail-closed。
65+
7. **`on_frame` 回调先在 session 层落地。** `run_playback`/`run_playback_mode`
66+
在 env 契约上声明 `on_frame: Callable[[int, np.ndarray], np.ndarray | None]`,
67+
但 unisim `SimBackend.run_playback` 尚未声明该参数;env 透传层在收到
68+
`on_frame` 时 fail-closed 抛 `NotImplementedError`,实际帧回调由
69+
`SnapshotPlaybackSession.render_snapshots(on_frame=...)` 提供,待上游补契约后
70+
再透传(见 Consequences)。
71+
8. **任务自有 overlay 走统一发现入口。** `ManagerBasedRlEnv` 提供
72+
`get_playback_debug_overlays()`:遍历 command manager 的 terms,聚合实现了
73+
`playback_debug_overlay_getter()` 的 term,把多 term 的原语按 env 合并成单个
74+
`DebugOverlayGetter`;无 provider 时返回 `None`。play 入口
75+
(`train_rsl_rl.play_rsl_rl`、`play_interactive.py`)通过
76+
`getattr(env, "get_playback_debug_overlays", None)` 发现任务 overlay,发现不到
77+
时回退现有特例(`curr_ee_goal_world` EE goal sphere、交互 viewer 的
78+
motion/reward/velocity 硬编码),既有行为不回归。
79+
9. **overlay 门控按 resolve 后的渲染模式区分。** 落点在
80+
`ABEnv.run_playback_mode`(`on_plan` 回调之后、dispatch 之前,plan 对象不被
81+
改写):record 模式要求 `supports_debug_overlay`,backend 不支持时维持
82+
fail-closed;interactive 模式要求
83+
`supports_interactive_debug_overlay`(unisim#54 起 mjwarp 报 True,其余
84+
backend 为 False),不满足时 `warnings.warn` 并丢弃 getter(传 None),
85+
交互回放照常进行——任务 opt-in overlay 不应让无位 backend 的交互回放整体
86+
不可用。
87+
88+
## Stable Contracts
89+
90+
- 任务侧叠加层的唯一数据契约是 `unisim.backend.base.DebugPrimitive` 列表
91+
(per-env、env 局部系);不允许再引入按位置数组或 backend 私有 geom 操作
92+
的叠加层入口。
93+
- `EnvPlayCapabilities.supports_debug_overlay` 是 env 侧判断叠加层可用性的
94+
唯一入口;interactive 模式的叠加层可用性由
95+
`supports_interactive_debug_overlay` 表达,门控统一在 `run_playback_mode`。
96+
- 任务自有 overlay 的唯一发现入口是 `ManagerBasedRlEnv.get_playback_debug_overlays()`
97+
(command terms 实现 `playback_debug_overlay_getter()`);play 入口用
98+
`getattr` 发现并回退现有特例,不再新增绕过发现机制的任务特例。
99+
- 自定义 eval 循环通过 `SnapshotPlaybackSession` 复用 record 管线,不在脚本里
100+
重新实现“快照缓存 + 事后渲染”。
101+
- camera 参数在 train/play 入口统一经 `camera_cfg_from_training` /
102+
`CameraCfg.from_kwargs` 归一化。
103+
104+
## Alternatives Considered
105+
106+
- 在 UniLab 复制 unisim 离线 worker 的 `_append_primitive` 实现供交互路径使用。
107+
拒绝原因:跨仓库复制同一绘制实现必然漂移;交互路径只需要
108+
sphere/box/frame/arrow 的 mjvScene 注入,过渡实现保持最小并明确等待上游
109+
公开 helper。
110+
- 交互 viewer 继续使用硬编码 `user_scn` 绘制、只迁移 record 管线。拒绝原因:
111+
两条路径的 overlay 语义会继续分叉,task 侧 debug 可视化无法在两种
112+
渲染路径间复用。
113+
- 自定义 eval 循环直接调用 `env.run_playback()` 并自行包一层 trial 协议。
114+
拒绝原因:`run_playback` 是单体的“initialize/step/渲染”整体入口,无法
115+
表达“逐 trial 攒帧、trial 结束统一出片”的协议,会导致下游复制 record
116+
管线内部逻辑。
117+
118+
## Consequences
119+
120+
- `extra_data_getter` 在 UniLab 全仓(含 docstring/tests)零残留;新叠加层
121+
代码必须使用 `DebugPrimitive`。
122+
- 交互注入器 `unilab/visualization/debug_primitives.py` 是过渡组件,当
123+
unisim 提供适合交互路径(单 env、已加载 model、调用方持有 mjvScene)的
124+
公开 helper(建议形态:`append_debug_primitives(scene, overlays, *,
125+
offsets=None, mesh_ids=None)`)后应改为委托并删除本地实现。
126+
- `on_frame` 透传依赖 unisim 在 `SimBackend.run_playback` 上声明同名参数;
127+
上游落地前 env 层 fail-closed。
128+
- UniLab 依赖 unisim PR #53 的契约;该 PR 合并并发版后需 bump
129+
`unisim-core` 最低版本。
130+
131+
## Evidence In Repo
132+
133+
- env 契约: `src/unilab/base/base.py`, `src/unilab/base/np_env.py`
134+
- 任务 overlay 发现: `src/unilab/envs/manager_based_rl_env.py`(`get_playback_debug_overlays`)
135+
- 交互注入器: `src/unilab/visualization/debug_primitives.py`
136+
- 可嵌入 session: `src/unilab/visualization/playback_session.py`
137+
- camera 归一化: `src/unilab/visualization/playback.py`
138+
- 交互 viewer 迁移: `src/unilab/scripts/play_interactive.py`
139+
- 训练入口迁移: `src/unilab/scripts/train_rsl_rl.py`, `src/unilab/scripts/train_appo.py`, `src/unilab/scripts/train_offpolicy.py`
140+
- 上游契约: `unisim.backend.base`(`DebugPrimitive`, `CameraCfg`, `DebugOverlayGetter`, `validate_debug_overlays`, `BackendPlayCapabilities.supports_debug_overlay` / `supports_interactive_debug_overlay`)
141+
- 测试: `tests/visualization/test_debug_primitives.py`, `tests/visualization/test_playback_session.py`, `tests/base/test_np_env_playback_contract.py`, `tests/envs/test_manager_based_rl_env.py`(overlay 聚合)
142+
143+
## Related Documents
144+
145+
- {doc}`ADR Index </adr/README>`
146+
- {doc}`ADR-0002 Backend Capability Boundary For Play And Snapshot </adr/ADR-0002-backend-capability-boundary-for-play-and-snapshot>`
147+
- {doc}`ADR-0007 UniSim Extraction Boundary </adr/ADR-0007-unisim-extraction-boundary>`

docs/sphinx/source/adr/ADR-0008-superdex-persistent-cpu-workers.md renamed to docs/sphinx/source/adr/ADR-0009-superdex-persistent-cpu-workers.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
orphan: true
33
---
44

5-
# ADR-0008 SuperDex Native C++ Scene Batch Executor
5+
# ADR-0009 SuperDex Native C++ Scene Batch Executor
66

77
- Status: Accepted
88
- Date: 2026-09-07

‎docs/sphinx/source/adr/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,8 @@ orphan: true
2121
| [ADR-0005 Unified Obs Critic Env And IPC Contract](ADR-0005-unified-obs-critic-env-and-ipc-contract.md) | Observation / IPC | Accepted |
2222
| [ADR-0006 Community Manager API On NumPy Runtime](ADR-0006-community-manager-api-on-numpy-runtime.md) | Manager API / NumPy runtime | Accepted |
2323
| [ADR-0007 UniSim Extraction Boundary](ADR-0007-unisim-extraction-boundary.md) | Physics package extraction | Accepted |
24-
| [ADR-0008 SuperDex Native C++ Scene Batch Executor](ADR-0008-superdex-persistent-cpu-workers.md) | Backend CPU scene execution | Accepted |
24+
| [ADR-0008 Debug Overlay Primitive Contract And Playback Session](ADR-0008-debug-overlay-primitive-contract-and-playback-session.md) | Debug overlay / playback session | Accepted |
25+
| [ADR-0009 SuperDex Native C++ Scene Batch Executor](ADR-0009-superdex-persistent-cpu-workers.md) | Backend CPU scene execution | Accepted |
2526

2627
## ADR Governance
2728

‎docs/sphinx/source/en/2-user_guide/3-backends/8-superdex.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ contact sensors and solver status without per-environment Python binding calls.
7979
Asset materialization, reset and cache-frame transforms remain owned by the
8080
UniSim adapter. This is CPU thread parallelism, not GPU physics, and it does
8181
not change the PPO/APPO collector, learner or policy contracts. The decision is
82-
recorded in {doc}`/adr/ADR-0008-superdex-persistent-cpu-workers` and
82+
recorded in {doc}`/adr/ADR-0009-superdex-persistent-cpu-workers` and
8383
[unisim#41](https://github.com/unilabsim/unisim/issues/41).
8484

8585
Both task owners select automatic workers by default:

‎docs/sphinx/source/zh_CN/2-user_guide/3-backends/8-superdex.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ C++ 线程池:每个子步批量写入广义力、推进 scene,并回写 art
6767
contact sensor 和 solver status,不再逐环境跨越 Python binding。资产物化、reset 和
6868
cache frame 转换仍由 UniSim adapter 负责。这是 CPU 线程并行,不是 GPU physics;它不改变
6969
PPO/APPO collector、learner 或 policy contract。决策见
70-
{doc}`/adr/ADR-0008-superdex-persistent-cpu-workers` 和
70+
{doc}`/adr/ADR-0009-superdex-persistent-cpu-workers` 和
7171
[unisim#41](https://github.com/unilabsim/unisim/issues/41)。
7272

7373
两个 task owner 默认选择自动 worker:

‎scripts/visualize_task_env.py‎

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from typing import TYPE_CHECKING, Any, cast, get_args, get_origin, get_type_hints
3333

3434
import numpy as np
35+
from unisim.backend.base import CameraCfg
3536

3637
ROOT_DIR = Path(__file__).parent.parent
3738
SRC_DIR = ROOT_DIR / "src"
@@ -165,7 +166,7 @@ def step(_obs):
165166
)
166167

167168

168-
def _motrix_camera_kwargs(env, num_envs: int) -> dict[str, Any] | None:
169+
def _motrix_camera_kwargs(env, num_envs: int) -> CameraCfg | None:
169170
"""Point Motrix's interactive camera at the actual terrain spawn cells."""
170171
spawn = getattr(env, "_spawn", None)
171172
origins_for = getattr(spawn, "origins_for", None)
@@ -194,12 +195,14 @@ def _motrix_camera_kwargs(env, num_envs: int) -> dict[str, Any] | None:
194195
lookat = origins[0].copy()
195196
distance = 4.0
196197
lookat[2] += 0.5
197-
return {
198-
"cam_lookat": lookat.tolist(),
199-
"cam_distance": distance,
200-
"cam_elevation": -25.0,
201-
"cam_azimuth": 135.0,
202-
}
198+
return CameraCfg.from_kwargs(
199+
{
200+
"cam_lookat": lookat.tolist(),
201+
"cam_distance": distance,
202+
"cam_elevation": -25.0,
203+
"cam_azimuth": 135.0,
204+
}
205+
)
203206

204207

205208
def _env_scene(env) -> "SceneCfg | None":

‎src/unilab/base/base.py‎

Lines changed: 52 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
import abc
2-
from collections.abc import Callable
2+
import warnings
3+
from collections.abc import Callable, Mapping
34
from dataclasses import dataclass
45
from os import PathLike
56
from typing import Any, Optional
67

78
import gymnasium as gym
89
import numpy as np
9-
from unisim.backend.base import BackendPlayRenderPlan
10+
from unisim.backend.base import BackendPlayRenderPlan, CameraCfg, DebugOverlayGetter
1011

1112
from .scene import SceneCfg
1213

14+
OnPlaybackFrameFn = Callable[[int, np.ndarray], "np.ndarray | None"]
15+
1316

1417
@dataclass(frozen=True)
1518
class EnvPlayCapabilities:
@@ -18,6 +21,8 @@ class EnvPlayCapabilities:
1821
supports_native_interactive_renderer: bool = False
1922
supports_physics_state_playback: bool = False
2023
supports_native_video_capture: bool = False
24+
supports_debug_overlay: bool = False
25+
supports_interactive_debug_overlay: bool = False
2126

2227

2328
@dataclass
@@ -285,10 +290,18 @@ def run_playback(
285290
headless: bool | None = None,
286291
record_video: bool | None = None,
287292
frame_state_getter: Callable[[], np.ndarray] | None = None,
288-
camera_kwargs: dict[str, Any] | None = None,
289-
extra_data_getter: Callable[[], np.ndarray | None] | None = None,
293+
camera_kwargs: CameraCfg | Mapping[str, Any] | None = None,
294+
debug_overlay_getter: DebugOverlayGetter | None = None,
295+
on_frame: OnPlaybackFrameFn | None = None,
290296
) -> str | None:
291-
"""Execute playback through the backend contract."""
297+
"""Execute playback through the backend contract.
298+
299+
``debug_overlay_getter`` returns per-env sequences of
300+
:class:`unisim.backend.base.DebugPrimitive` with env-local poses
301+
(``None`` disables overlays for the frame). ``on_frame`` is an
302+
optional ``(frame_index, frame) -> frame | None`` callback applied to
303+
each recorded frame before it is written to the video.
304+
"""
292305
raise NotImplementedError(f"{self.__class__.__name__} does not support playback execution")
293306

294307
def run_playback_mode(
@@ -302,11 +315,20 @@ def run_playback_mode(
302315
render_spacing: float | None = None,
303316
render_offset_mode: str | None = None,
304317
frame_state_getter: Callable[[], np.ndarray] | None = None,
305-
camera_kwargs: dict[str, Any] | None = None,
306-
extra_data_getter: Callable[[], np.ndarray | None] | None = None,
318+
camera_kwargs: CameraCfg | Mapping[str, Any] | None = None,
319+
debug_overlay_getter: DebugOverlayGetter | None = None,
320+
on_frame: OnPlaybackFrameFn | None = None,
307321
on_plan: Callable[[BackendPlayRenderPlan], None] | None = None,
308322
) -> str | None:
309-
"""Resolve configured playback mode and execute it through the backend contract."""
323+
"""Resolve configured playback mode and execute it through the backend contract.
324+
325+
Overlay gating follows the resolved plan mode: ``record`` playback
326+
requires ``play_capabilities.supports_debug_overlay`` (backends fail
327+
closed otherwise); ``interactive`` playback requires
328+
``supports_interactive_debug_overlay`` — when the backend lacks it the
329+
getter is dropped with a warning so interactive playback still runs
330+
without overlays.
331+
"""
310332
plan = self.resolve_play_render_plan(
311333
play_render_mode=play_render_mode,
312334
play_steps=play_steps,
@@ -316,6 +338,14 @@ def run_playback_mode(
316338
on_plan(plan)
317339
if plan.mode == "none":
318340
return None
341+
if plan.mode == "interactive" and debug_overlay_getter is not None:
342+
if not self.play_capabilities.supports_interactive_debug_overlay:
343+
warnings.warn(
344+
f"{self.__class__.__name__} backend does not support interactive debug "
345+
"overlays; dropping debug_overlay_getter for this interactive playback",
346+
stacklevel=2,
347+
)
348+
debug_overlay_getter = None
319349
return self.run_playback(
320350
initialize=initialize,
321351
step=step,
@@ -327,7 +357,8 @@ def run_playback_mode(
327357
record_video=plan.record_video,
328358
frame_state_getter=frame_state_getter,
329359
camera_kwargs=camera_kwargs,
330-
extra_data_getter=extra_data_getter,
360+
debug_overlay_getter=debug_overlay_getter,
361+
on_frame=on_frame,
331362
)
332363

333364
@property
@@ -385,13 +416,24 @@ def init_play_renderer(
385416
capture: bool = False,
386417
width: int = 1280,
387418
height: int = 720,
388-
camera_kwargs: dict[str, Any] | None = None,
419+
camera_kwargs: CameraCfg | Mapping[str, Any] | None = None,
389420
) -> None:
390421
"""Initialize env-facing playback rendering when supported."""
391422
raise NotImplementedError(
392423
f"{self.__class__.__name__} does not support native playback rendering"
393424
)
394425

426+
def render(self, mode: str = "rgb_array") -> np.ndarray:
427+
"""Render the current state; ``mode="rgb_array"`` returns an RGB frame.
428+
429+
Convenience wrapper over :meth:`init_play_renderer` and
430+
:meth:`capture_play_video_frame`, gated on
431+
``play_capabilities.supports_native_video_capture``.
432+
"""
433+
raise NotImplementedError(
434+
f"{self.__class__.__name__} does not support render(mode={mode!r})"
435+
)
436+
395437
def render_play_frame(self) -> None:
396438
"""Render one frame through the env-facing interactive playback contract."""
397439
raise NotImplementedError(

0 commit comments

Comments
 (0)