Skip to content

Commit 2efa2d2

Browse files
authored
Merge pull request #224 from unilabsim/fix/issue-222-backend-capability-contract
fix: formalize backend play and snapshot capabilities
2 parents dc2d25c + 4172a96 commit 2efa2d2

13 files changed

Lines changed: 260 additions & 39 deletions

File tree

‎scripts/play_interactive.py‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -505,12 +505,10 @@ def play_interactive(args):
505505
)
506506

507507
# Dedicated MjData for the viewer (never touches the rollout workers)
508-
if hasattr(env, "_backend") and hasattr(env._backend, "model"):
509-
mj_model = env._backend.model
510-
else:
511-
raise AttributeError(
512-
"Environment backend does not expose a MuJoCo model via env._backend.model"
513-
)
508+
try:
509+
mj_model = env.get_playback_model()
510+
except NotImplementedError as exc:
511+
raise AttributeError("Environment does not expose a playback model contract") from exc
514512
viz_data = mujoco.MjData(mj_model)
515513
state_spec = mujoco.mjtState.mjSTATE_FULLPHYSICS
516514
ctrl_dt = env.cfg.ctrl_dt
@@ -555,7 +553,7 @@ def _on_key(keycode: int) -> None:
555553
obs, _, _, _ = wrapped_env.step(actions)
556554

557555
# Push env state[0] into viz_data and refresh scene
558-
phys = env._backend.get_physics_state()[0].astype(np.float64)
556+
phys = env.get_physics_state_snapshot()[0].astype(np.float64)
559557
mujoco.mj_setState(mj_model, viz_data, phys, state_spec)
560558
mujoco.mj_forward(mj_model, viz_data)
561559

‎scripts/train_appo.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -236,7 +236,6 @@ def play_appo(cfg: DictConfig, rl_cfg: dict[str, Any]) -> str | None:
236236
).obs["obs"],
237237
dtype=np.float32,
238238
),
239-
frame_state_getter=lambda: env._backend.get_physics_state(),
240239
camera_kwargs={
241240
"cam_distance": cfg.training.cam_distance,
242241
"cam_elevation": cfg.training.cam_elevation,

‎scripts/train_mlx_ppo.py‎

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -128,15 +128,6 @@ def build_model(cfg, obs_dim: int, action_dim: int, dtype=mx.float32) -> MLPActo
128128
)
129129

130130

131-
def _get_physics_state_snapshot(env) -> np.ndarray:
132-
"""Get physics state in a backend-compatible way for MuJoCo video rendering."""
133-
if hasattr(env, "_backend") and hasattr(env._backend, "get_physics_state"):
134-
return np.asarray(env._backend.get_physics_state(), dtype=np.float32).copy()
135-
if hasattr(env, "state") and hasattr(env.state, "physics_state"):
136-
return np.asarray(env.state.physics_state, dtype=np.float32).copy()
137-
raise AttributeError("Env backend does not expose physics state for video rendering")
138-
139-
140131
def _get_log_root(cfg: DictConfig) -> Path:
141132
return cast(Path, get_log_root(ROOT_DIR, cfg))
142133

@@ -225,7 +216,6 @@ def _play_step(current_obs):
225216
output_video=output_video,
226217
initialize=lambda: obs,
227218
step=_play_step,
228-
frame_state_getter=lambda: _get_physics_state_snapshot(env),
229219
)
230220
except ImportError:
231221
print("mediapy is required for play video export. Install with `pip install mediapy`.")

‎scripts/train_offpolicy.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -364,7 +364,6 @@ def _policy_step(obs_np: np.ndarray) -> np.ndarray:
364364
dtype=np.float32,
365365
),
366366
step=_policy_step,
367-
frame_state_getter=lambda: env._backend.get_physics_state(),
368367
)
369368
print(f"Saving video to {output_video} ...")
370369
print("Done.")

‎scripts/train_rsl_rl.py‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,6 @@ def play_rsl_rl(cfg: DictConfig, device: str) -> str | None:
141141
output_video=output_video,
142142
initialize=lambda: wrapped_env.reset()[0],
143143
step=lambda obs: wrapped_env.step(policy(obs))[0],
144-
frame_state_getter=lambda: env._backend.get_physics_state(),
145144
camera_kwargs={
146145
"cam_distance": cfg.training.cam_distance,
147146
"cam_elevation": cfg.training.cam_elevation,

‎src/unilab/base/backend/base.py‎

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import abc
22
from collections.abc import Sequence
3+
from dataclasses import dataclass
34

45
import numpy as np
56

@@ -10,6 +11,14 @@
1011
)
1112

1213

14+
@dataclass(frozen=True)
15+
class BackendPlayCapabilities:
16+
"""Backend-native play/render capabilities surfaced through env contracts."""
17+
18+
supports_native_interactive_renderer: bool = False
19+
supports_physics_state_playback: bool = False
20+
21+
1322
class SimBackend(abc.ABC):
1423
"""仿真后端统一接口"""
1524

@@ -131,6 +140,28 @@ def get_dr_capabilities(self) -> DomainRandomizationCapabilities:
131140
def apply_interval_randomization(self, plan: IntervalRandomizationPlan) -> None:
132141
"""Apply a scheduled interval randomization plan."""
133142

143+
def get_play_capabilities(self) -> BackendPlayCapabilities:
144+
"""Return backend-native play/render capabilities."""
145+
return BackendPlayCapabilities()
146+
147+
def init_renderer(self, spacing: float = 1.0) -> None:
148+
"""Initialize a backend-native interactive renderer."""
149+
raise NotImplementedError(
150+
f"{self.__class__.__name__} does not support native interactive rendering"
151+
)
152+
153+
def render(self) -> None:
154+
"""Render one frame through a backend-native interactive renderer."""
155+
raise NotImplementedError(
156+
f"{self.__class__.__name__} does not support native interactive rendering"
157+
)
158+
159+
def get_physics_state(self) -> np.ndarray:
160+
"""Return a physics snapshot suitable for offline playback/video export."""
161+
raise NotImplementedError(
162+
f"{self.__class__.__name__} does not support physics-state playback"
163+
)
164+
134165
# ------------------------------------------------------------------ #
135166
# Base kinematics #
136167
# ------------------------------------------------------------------ #

‎src/unilab/base/backend/motrix_backend.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
except ImportError:
2121
MOTRIX_AVAILABLE = False
2222

23-
from .base import SimBackend
23+
from .base import BackendPlayCapabilities, SimBackend
2424

2525

2626
class MotrixBackend(SimBackend):
@@ -218,6 +218,9 @@ def apply_interval_randomization(self, plan: IntervalRandomizationPlan) -> None:
218218
return
219219
self.push_robots(np.asarray(plan.push_perturbation_limit))
220220

221+
def get_play_capabilities(self) -> BackendPlayCapabilities:
222+
return BackendPlayCapabilities(supports_native_interactive_renderer=True)
223+
221224
# ------------------------------------------------------------------ #
222225
# Base kinematics #
223226
# ------------------------------------------------------------------ #

‎src/unilab/base/backend/mujoco_backend.py‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
)
2222

2323
from ..dtype_config import get_global_dtype
24-
from .base import SimBackend
24+
from .base import BackendPlayCapabilities, SimBackend
2525

2626

2727
def _root_state_dims(model) -> tuple[int, int]:
@@ -308,6 +308,9 @@ def apply_interval_randomization(self, plan: IntervalRandomizationPlan) -> None:
308308
velocity_delta *= np.asarray(plan.push_perturbation_limit, dtype=np.float64)
309309
self._base_lin_vel_view[:] += velocity_delta.astype(self._np_dtype)
310310

311+
def get_play_capabilities(self) -> BackendPlayCapabilities:
312+
return BackendPlayCapabilities(supports_physics_state_playback=True)
313+
311314
# ------------------------------------------------------------------ #
312315
# Base kinematics #
313316
# ------------------------------------------------------------------ #

‎src/unilab/base/base.py‎

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66
import numpy as np
77

88

9+
@dataclass(frozen=True)
10+
class EnvPlayCapabilities:
11+
"""Env-facing play/render capabilities consumed by training entrypoints."""
12+
13+
supports_native_interactive_renderer: bool = False
14+
supports_physics_state_playback: bool = False
15+
16+
917
@dataclass
1018
class EnvCfg:
1119
"""
@@ -45,6 +53,11 @@ def validate(self):
4553

4654

4755
class ABEnv(abc.ABC):
56+
@property
57+
def play_capabilities(self) -> EnvPlayCapabilities:
58+
"""Return env-facing play/render capabilities."""
59+
return EnvPlayCapabilities()
60+
4861
@property
4962
@abc.abstractmethod
5063
def num_envs(self) -> int:
@@ -90,3 +103,25 @@ def step(self, actions: np.ndarray) -> Any:
90103
@abc.abstractmethod
91104
def close(self) -> None:
92105
"""Clean up environment resources"""
106+
107+
def init_play_renderer(self, render_spacing: float | None = None) -> None:
108+
"""Initialize env-facing interactive playback when supported."""
109+
raise NotImplementedError(
110+
f"{self.__class__.__name__} does not support native interactive playback"
111+
)
112+
113+
def render_play_frame(self) -> None:
114+
"""Render one frame through the env-facing interactive playback contract."""
115+
raise NotImplementedError(
116+
f"{self.__class__.__name__} does not support native interactive playback"
117+
)
118+
119+
def get_physics_state_snapshot(self) -> np.ndarray:
120+
"""Return a physics snapshot for offline playback/video export."""
121+
raise NotImplementedError(
122+
f"{self.__class__.__name__} does not support physics-state playback"
123+
)
124+
125+
def get_playback_model(self) -> Any:
126+
"""Return a model object suitable for backend-specific playback tooling."""
127+
raise NotImplementedError(f"{self.__class__.__name__} does not expose a playback model")

‎src/unilab/base/np_env.py‎

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import numpy as np
1010

1111
from unilab.base.backend import SimBackend
12-
from unilab.base.base import ABEnv, EnvCfg
12+
from unilab.base.base import ABEnv, EnvCfg, EnvPlayCapabilities
1313
from unilab.base.dtype_config import get_global_dtype
1414
from unilab.dr import DomainRandomizationManager, DomainRandomizationProvider
1515

@@ -200,6 +200,40 @@ def _compute_truncated(self, state: NpEnvState) -> np.ndarray:
200200
np.greater_equal(state.info["steps"], self._cfg.max_episode_steps, out=truncated)
201201
return truncated
202202

203+
def init_play_renderer(self, render_spacing: float | None = None) -> None:
204+
"""Initialize backend-native interactive playback when available."""
205+
if not self.play_capabilities.supports_native_interactive_renderer:
206+
raise NotImplementedError(
207+
f"{self._backend.__class__.__name__} does not support native interactive playback"
208+
)
209+
if render_spacing is None:
210+
self._backend.init_renderer()
211+
return
212+
try:
213+
self._backend.init_renderer(spacing=render_spacing)
214+
except TypeError:
215+
self._backend.init_renderer()
216+
217+
def render_play_frame(self) -> None:
218+
"""Render one interactive playback frame through the env contract."""
219+
if not self.play_capabilities.supports_native_interactive_renderer:
220+
raise NotImplementedError(
221+
f"{self._backend.__class__.__name__} does not support native interactive playback"
222+
)
223+
self._backend.render()
224+
225+
def get_physics_state_snapshot(self) -> np.ndarray:
226+
"""Return a detached physics snapshot for offline playback/video export."""
227+
if not self.play_capabilities.supports_physics_state_playback:
228+
raise NotImplementedError(
229+
f"{self._backend.__class__.__name__} does not support physics-state playback"
230+
)
231+
physics_state = cast(
232+
np.ndarray, np.asarray(self._backend.get_physics_state(), dtype=np.float32)
233+
)
234+
snapshot = cast(np.ndarray, physics_state.copy())
235+
return snapshot
236+
203237
@abc.abstractmethod
204238
def apply_action(self, actions: np.ndarray, state: NpEnvState) -> np.ndarray:
205239
"""子类实现:action → ctrl"""
@@ -208,6 +242,22 @@ def apply_action(self, actions: np.ndarray, state: NpEnvState) -> np.ndarray:
208242
def update_state(self, state: NpEnvState) -> NpEnvState:
209243
"""子类实现:计算 obs/reward/terminated"""
210244

245+
@property
246+
def play_capabilities(self) -> EnvPlayCapabilities:
247+
capabilities = self._backend.get_play_capabilities()
248+
return EnvPlayCapabilities(
249+
supports_native_interactive_renderer=capabilities.supports_native_interactive_renderer,
250+
supports_physics_state_playback=capabilities.supports_physics_state_playback,
251+
)
252+
253+
def get_playback_model(self) -> Any:
254+
if not self._supports_backend_property("model"):
255+
raise NotImplementedError(f"{self.__class__.__name__} does not expose a playback model")
256+
return self._backend.model
257+
211258
def close(self) -> None:
212259
"""关闭环境"""
213260
pass
261+
262+
def _supports_backend_property(self, name: str) -> bool:
263+
return hasattr(self._backend, name)

0 commit comments

Comments
 (0)