Skip to content

Commit bef0b0a

Browse files
authored
Merge pull request #280 from RobotControlStack/feat/sim-state-record-replay
feat(sim): record and replay MuJoCo state
2 parents 0001be5 + e83535b commit bef0b0a

12 files changed

Lines changed: 507 additions & 4 deletions

File tree

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ dependencies = [
4040
"mujoco==3.2.6",
4141
"pin==3.7.0",
4242
"greenlet",
43+
"duckdb",
4344
]
4445
readme = "README.md"
4546
maintainers = [{ name = "Tobias Jülg", email = "tobias.juelg@utn.de" }]

python/rcs/__main__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@
33

44
import typer
55
from rcs.envs.storage_wrapper import StorageWrapper
6+
from rcs.sim_state_replay import replay as replay_command
67

78
app = typer.Typer()
9+
app.command()(replay_command)
810

911

1012
@app.command()

python/rcs/_core/sim.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ class Sim:
9999
def set_config(self, cfg: SimConfig) -> bool: ...
100100
def step(self, k: int) -> None: ...
101101
def step_until_convergence(self) -> None: ...
102+
def sync_gui(self) -> None: ...
102103

103104
class SimCameraConfig(rcs._core.common.BaseCameraConfig):
104105
type: CameraType

python/rcs/envs/sim.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,23 @@ def reset(
4040
return super().reset(seed=seed, options=options)
4141

4242

43+
class SimStateObservationWrapper(ActObsInfoWrapper):
44+
STATE_KEY = "sim_state"
45+
STATE_SPEC_KEY = "sim_state_spec"
46+
47+
def __init__(self, env):
48+
super().__init__(env)
49+
assert self.env.get_wrapper_attr("PLATFORM") == RobotPlatform.SIMULATION, "Base environment must be simulation."
50+
self.sim = cast(sim.Sim, self.get_wrapper_attr("sim"))
51+
52+
def observation(self, observation: dict[str, Any], info: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
53+
observation = dict(observation)
54+
sim_state = self.sim.get_state()
55+
observation[self.STATE_KEY] = sim_state
56+
observation[self.STATE_SPEC_KEY] = self.sim.get_state_spec()
57+
return observation, info
58+
59+
4360
class GripperWrapperSim(ActObsInfoWrapper):
4461
def __init__(self, env):
4562
super().__init__(env)

python/rcs/sim/sim.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
import mujoco as mj
1313
import mujoco.viewer
14+
import numpy as np
1415
from rcs._core.sim import GuiClient as _GuiClient
1516
from rcs._core.sim import Sim as _Sim
1617
from rcs.sim import SimConfig, egl_bootstrap
@@ -44,6 +45,8 @@ def gui_loop(gui_uuid: str, close_event):
4445

4546

4647
class Sim(_Sim):
48+
STATE_SPEC = mj.mjtState.mjSTATE_INTEGRATION
49+
4750
def __init__(self, mjmdl: str | PathLike | ModelComposer, cfg: SimConfig | None = None):
4851
if isinstance(mjmdl, ModelComposer):
4952
self.model = mjmdl.get_model()
@@ -68,6 +71,32 @@ def __init__(self, mjmdl: str | PathLike | ModelComposer, cfg: SimConfig | None
6871
if cfg is not None:
6972
self.set_config(cfg)
7073

74+
def get_state_spec(self) -> int:
75+
return int(self.STATE_SPEC)
76+
77+
def get_state_size(self, spec: int | None = None) -> int:
78+
state_spec = self.STATE_SPEC if spec is None else mj.mjtState(spec)
79+
return mj.mj_stateSize(self.model, state_spec)
80+
81+
def get_state(self, spec: int | None = None) -> np.ndarray:
82+
state_spec = self.STATE_SPEC if spec is None else mj.mjtState(spec)
83+
state = np.empty(self.get_state_size(int(state_spec)), dtype=np.float64)
84+
mj.mj_getState(self.model, self.data, state, state_spec)
85+
return state
86+
87+
def set_state(self, state: np.ndarray, spec: int | None = None):
88+
state_spec = self.STATE_SPEC if spec is None else mj.mjtState(spec)
89+
state_array = np.asarray(state, dtype=np.float64)
90+
expected_size = self.get_state_size(int(state_spec))
91+
if state_array.shape != (expected_size,):
92+
msg = (
93+
f"Expected MuJoCo state with shape ({expected_size},), "
94+
f"got {state_array.shape} for spec {int(state_spec)}."
95+
)
96+
raise ValueError(msg)
97+
mj.mj_setState(self.model, self.data, state_array, state_spec)
98+
mj.mj_forward(self.model, self.data)
99+
71100
def close_gui(self):
72101
if self._stop_event is not None:
73102
self._stop_event.set()

python/rcs/sim_state_replay.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
from __future__ import annotations
2+
3+
import time
4+
from dataclasses import dataclass
5+
from pathlib import Path
6+
from typing import Annotated, Any
7+
8+
import gymnasium as gym
9+
import numpy as np
10+
import pyarrow.compute as pc
11+
import pyarrow.dataset as ds
12+
import typer
13+
from PIL import Image
14+
from rcs.envs.base import ControlMode
15+
from rcs.envs.sim import SimStateObservationWrapper
16+
17+
import rcs # noqa: F401
18+
19+
app = typer.Typer(help="Replay recorded MuJoCo trajectories from a parquet dataset.")
20+
21+
DATASET_ARGUMENT = typer.Argument(..., exists=True, file_okay=False, dir_okay=True)
22+
23+
ENV_ID_OPTION = typer.Option(help="Gymnasium env id used for replay.")
24+
TRAJECTORY_UUID_OPTION = typer.Option(help="UUID of the recorded trajectory to replay.")
25+
CAMERA_OPTION = typer.Option("--camera", help="Camera names to enable on the replay env.")
26+
RESOLUTION_OPTION = typer.Option(help="Replay camera resolution as WIDTH HEIGHT.")
27+
FRAME_RATE_OPTION = typer.Option(help="Replay camera frame rate.")
28+
RENDER_MODE_OPTION = typer.Option(help="Gym render mode for the replay env.")
29+
CONTROL_MODE_OPTION = typer.Option(help="Control mode name for env creation.")
30+
SLEEP_OPTION = typer.Option(help="Optional delay between restored states.")
31+
OUTPUT_DIR_OPTION = typer.Option(help="Optional directory for re-rendered RGB frames.")
32+
PREFER_DUCKDB_OPTION = typer.Option(help="Use duckdb for parquet loading when it is available.")
33+
34+
35+
@dataclass(frozen=True)
36+
class RecordedSimStep:
37+
step: int
38+
uuid: str
39+
timestamp: float | None
40+
observation: dict[str, Any]
41+
42+
@property
43+
def sim_state(self) -> np.ndarray:
44+
return np.asarray(self.observation[SimStateObservationWrapper.STATE_KEY], dtype=np.float64)
45+
46+
@property
47+
def sim_state_spec(self) -> int:
48+
return int(self.observation.get(SimStateObservationWrapper.STATE_SPEC_KEY, 0))
49+
50+
51+
class DuckDBUnavailableError(RuntimeError):
52+
pass
53+
54+
55+
def _get_duckdb_module():
56+
try:
57+
import duckdb
58+
except ModuleNotFoundError as exc:
59+
msg = (
60+
"duckdb is required for the preferred parquet read path but is not installed. "
61+
"Install the 'duckdb' Python package or rely on the pyarrow fallback in library calls."
62+
)
63+
raise DuckDBUnavailableError(msg) from exc
64+
return duckdb
65+
66+
67+
def _load_distinct_uuids_with_duckdb(dataset_path: Path) -> list[str]:
68+
duckdb = _get_duckdb_module()
69+
connection = duckdb.connect()
70+
try:
71+
rows = connection.execute(
72+
"SELECT DISTINCT uuid FROM read_parquet(?) ORDER BY uuid",
73+
[str(dataset_path)],
74+
).fetchall()
75+
finally:
76+
connection.close()
77+
return [row[0] for row in rows]
78+
79+
80+
def _load_distinct_uuids_with_pyarrow(dataset_path: Path) -> list[str]:
81+
dataset = ds.dataset(str(dataset_path), format="parquet")
82+
uuids = dataset.to_table(columns=["uuid"])["uuid"]
83+
return sorted(str(uuid) for uuid in pc.unique(uuids).to_pylist()) # type: ignore
84+
85+
86+
def list_trajectory_ids(dataset_path: Path, prefer_duckdb: bool = True) -> list[str]:
87+
if prefer_duckdb:
88+
try:
89+
return _load_distinct_uuids_with_duckdb(dataset_path)
90+
except DuckDBUnavailableError:
91+
pass
92+
return _load_distinct_uuids_with_pyarrow(dataset_path)
93+
94+
95+
def _load_trajectory_with_duckdb(dataset_path: Path, trajectory_uuid: str) -> list[RecordedSimStep]:
96+
duckdb = _get_duckdb_module()
97+
connection = duckdb.connect()
98+
try:
99+
table = connection.execute(
100+
"SELECT uuid, step, timestamp, obs FROM read_parquet(?) WHERE uuid = ? ORDER BY step",
101+
[str(dataset_path), trajectory_uuid],
102+
).to_arrow_table()
103+
finally:
104+
connection.close()
105+
return [
106+
RecordedSimStep(
107+
step=int(row["step"]),
108+
uuid=str(row["uuid"]),
109+
timestamp=float(row["timestamp"]) if row["timestamp"] is not None else None,
110+
observation=row["obs"],
111+
)
112+
for row in table.to_pylist()
113+
]
114+
115+
116+
def _load_trajectory_with_pyarrow(dataset_path: Path, trajectory_uuid: str) -> list[RecordedSimStep]:
117+
dataset = ds.dataset(str(dataset_path), format="parquet")
118+
table = dataset.to_table(filter=pc.field("uuid") == trajectory_uuid, columns=["uuid", "step", "timestamp", "obs"])
119+
rows = table.sort_by([("step", "ascending")]).to_pylist()
120+
return [
121+
RecordedSimStep(
122+
step=int(row["step"]),
123+
uuid=str(row["uuid"]),
124+
timestamp=float(row["timestamp"]) if row["timestamp"] is not None else None,
125+
observation=row["obs"],
126+
)
127+
for row in rows
128+
]
129+
130+
131+
def load_trajectory(dataset_path: Path, trajectory_uuid: str, prefer_duckdb: bool = True) -> list[RecordedSimStep]:
132+
if prefer_duckdb:
133+
try:
134+
return _load_trajectory_with_duckdb(dataset_path, trajectory_uuid)
135+
except DuckDBUnavailableError:
136+
pass
137+
return _load_trajectory_with_pyarrow(dataset_path, trajectory_uuid)
138+
139+
140+
def resolve_trajectory_uuid(dataset_path: Path, trajectory_uuid: str | None, prefer_duckdb: bool = True) -> str:
141+
if trajectory_uuid is not None:
142+
return trajectory_uuid
143+
available_uuids = list_trajectory_ids(dataset_path, prefer_duckdb=prefer_duckdb)
144+
if len(available_uuids) == 1:
145+
return available_uuids[0]
146+
msg = (
147+
f"Dataset {dataset_path} contains {len(available_uuids)} trajectories. "
148+
f"Pass --trajectory-uuid and choose one of: {available_uuids}"
149+
)
150+
raise ValueError(msg)
151+
152+
153+
def restore_sim_step(env: gym.Env, recorded_step: RecordedSimStep):
154+
sim = env.get_wrapper_attr("sim")
155+
sim.set_state(recorded_step.sim_state, spec=recorded_step.sim_state_spec)
156+
157+
158+
def collect_rgb_frames(env: gym.Env) -> dict[str, np.ndarray]:
159+
try:
160+
camera_set = env.get_wrapper_attr("camera_set")
161+
except AttributeError:
162+
return {}
163+
164+
frameset = camera_set.get_latest_frames()
165+
if frameset is None:
166+
return {}
167+
168+
rgb_frames: dict[str, np.ndarray] = {}
169+
for camera_name, frame in frameset.frames.items():
170+
lower_name = camera_name.lower()
171+
if "digit" in lower_name or "tactile" in lower_name:
172+
continue
173+
rgb_frames[camera_name] = np.asarray(frame.camera.color.data)
174+
return rgb_frames
175+
176+
177+
def save_rgb_frames(output_dir: Path, recorded_step: RecordedSimStep, rgb_frames: dict[str, np.ndarray]):
178+
output_dir.mkdir(parents=True, exist_ok=True)
179+
for camera_name, rgb_frame in rgb_frames.items():
180+
Image.fromarray(rgb_frame).save(output_dir / f"step-{recorded_step.step:06d}-{camera_name}.png")
181+
182+
183+
def replay_trajectory(
184+
env: gym.Env,
185+
recorded_steps: list[RecordedSimStep],
186+
*,
187+
sleep_s: float = 0.0,
188+
output_dir: Path | None = None,
189+
):
190+
if not recorded_steps:
191+
msg = "No recorded sim states found in the requested trajectory."
192+
raise ValueError(msg)
193+
194+
env.reset()
195+
sim = env.get_wrapper_attr("sim")
196+
for recorded_step in recorded_steps:
197+
restore_sim_step(env, recorded_step)
198+
if output_dir is not None:
199+
save_rgb_frames(output_dir, recorded_step, collect_rgb_frames(env))
200+
sim.sync_gui()
201+
if sleep_s > 0:
202+
time.sleep(sleep_s)
203+
204+
205+
@app.command()
206+
def replay(
207+
dataset: Annotated[Path, DATASET_ARGUMENT],
208+
env_id: Annotated[str, ENV_ID_OPTION] = "rcs/FR3SimplePickUpSim-v0",
209+
trajectory_uuid: Annotated[str | None, TRAJECTORY_UUID_OPTION] = None,
210+
camera: Annotated[list[str] | None, CAMERA_OPTION] = None,
211+
resolution: Annotated[tuple[int, int], RESOLUTION_OPTION] = (256, 256),
212+
frame_rate: Annotated[int, FRAME_RATE_OPTION] = 0,
213+
render_mode: Annotated[str, RENDER_MODE_OPTION] = "human",
214+
control_mode: Annotated[str, CONTROL_MODE_OPTION] = ControlMode.CARTESIAN_TRPY.name,
215+
sleep_s: Annotated[float, SLEEP_OPTION] = 0.0,
216+
output_dir: Annotated[Path | None, OUTPUT_DIR_OPTION] = None,
217+
prefer_duckdb: Annotated[bool, PREFER_DUCKDB_OPTION] = True,
218+
):
219+
if camera is None:
220+
camera = []
221+
resolved_uuid = resolve_trajectory_uuid(dataset, trajectory_uuid, prefer_duckdb=prefer_duckdb)
222+
env = gym.make(
223+
env_id,
224+
render_mode=render_mode,
225+
control_mode=ControlMode[control_mode],
226+
resolution=resolution,
227+
frame_rate=frame_rate,
228+
cam_list=camera,
229+
)
230+
try:
231+
recorded_steps = load_trajectory(dataset, resolved_uuid, prefer_duckdb=prefer_duckdb)
232+
replay_trajectory(env, recorded_steps, sleep_s=sleep_s, output_dir=output_dir)
233+
finally:
234+
env.close()
235+
236+
typer.echo(f"Replayed {len(recorded_steps)} steps from trajectory {resolved_uuid}.")
237+
if output_dir is not None:
238+
typer.echo(f"Saved re-rendered RGB frames to {output_dir}.")
239+
240+
241+
if __name__ == "__main__":
242+
app()

0 commit comments

Comments
 (0)