simrig 0.2.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
simrig/__init__.py ADDED
@@ -0,0 +1,82 @@
1
+ """SimRig public API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from simrig._version import __version__
8
+ from simrig.core import (
9
+ BackendInfo,
10
+ EnvInspectionReport,
11
+ ModelInspectionReport,
12
+ RunConfig,
13
+ SmokeResult,
14
+ TrainabilityStatus,
15
+ )
16
+ from simrig.mujoco_backend import inspect_model, list_models
17
+ from simrig.playground_backend import (
18
+ demo_policy,
19
+ eval_policy,
20
+ inspect_env,
21
+ list_envs,
22
+ smoke_env,
23
+ train_ppo,
24
+ )
25
+ from simrig.scaffold import new_env
26
+ from simrig.validate_env import EnvValidationResult, validate_env
27
+
28
+ __all__ = [
29
+ "__version__",
30
+ "BackendInfo",
31
+ "EnvInspectionReport",
32
+ "EnvValidationResult",
33
+ "ModelInspectionReport",
34
+ "RunConfig",
35
+ "SmokeResult",
36
+ "TrainabilityStatus",
37
+ "demo_policy",
38
+ "eval_policy",
39
+ "inspect_env",
40
+ "inspect_model",
41
+ "is_env_module_path",
42
+ "list_envs",
43
+ "list_models",
44
+ "LiveWebViewer",
45
+ "load_custom_env",
46
+ "load_env",
47
+ "new_env",
48
+ "smoke_env",
49
+ "serve_model_view",
50
+ "serve_policy_preview",
51
+ "train_ppo",
52
+ "validate_env",
53
+ ]
54
+
55
+
56
+ def __getattr__(name: str) -> Any:
57
+ """Lazy-load browser helpers so core imports work without numpy/mujoco."""
58
+ if name == "LiveWebViewer":
59
+ from simrig.live_view import LiveWebViewer
60
+
61
+ return LiveWebViewer
62
+ if name == "serve_model_view":
63
+ from simrig.model_view import serve_model_view
64
+
65
+ return serve_model_view
66
+ if name == "serve_policy_preview":
67
+ from simrig.preview import serve_policy_preview
68
+
69
+ return serve_policy_preview
70
+ if name == "is_env_module_path":
71
+ from simrig.custom_env import is_env_module_path
72
+
73
+ return is_env_module_path
74
+ if name == "load_custom_env":
75
+ from simrig.custom_env import load_custom_env
76
+
77
+ return load_custom_env
78
+ if name == "load_env":
79
+ from simrig.playground_backend import load_env
80
+
81
+ return load_env
82
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
simrig/_version.py ADDED
@@ -0,0 +1,3 @@
1
+ """Single source of truth for the SimRig package version."""
2
+
3
+ __version__ = "0.2.2"
@@ -0,0 +1,145 @@
1
+ """Background MuJoCo frame rendering for browser viewers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from typing import Any, Callable
8
+
9
+ import numpy as np
10
+
11
+ from simrig.rendering import CameraState, create_mujoco_renderer
12
+
13
+
14
+ def encode_jpeg(image_module: Any, frame: np.ndarray, *, quality: int = 72) -> bytes:
15
+ from io import BytesIO
16
+
17
+ image = image_module.fromarray(np.asarray(frame))
18
+ output = BytesIO()
19
+ image.save(output, format="JPEG", quality=quality, optimize=True)
20
+ return output.getvalue()
21
+
22
+
23
+ class MujocoFramePump:
24
+ """Render MuJoCo frames on a dedicated thread and serve cached JPEGs."""
25
+
26
+ def __init__(
27
+ self,
28
+ mujoco: Any,
29
+ model: Any,
30
+ mj_data: Any,
31
+ *,
32
+ width: int,
33
+ height: int,
34
+ camera: Any,
35
+ camera_state: CameraState,
36
+ image_module: Any,
37
+ fps: int = 24,
38
+ jpeg_quality: int = 72,
39
+ render_mode: str = "mujoco",
40
+ scene_lock: threading.Lock | None = None,
41
+ before_render: Callable[[], None] | None = None,
42
+ fallback_frame: Callable[[], np.ndarray] | None = None,
43
+ error_frame: Callable[[str], np.ndarray] | None = None,
44
+ ) -> None:
45
+ self.mujoco = mujoco
46
+ self.model = model
47
+ self.mj_data = mj_data
48
+ self.width = width
49
+ self.height = height
50
+ self.camera = camera
51
+ self.camera_state = camera_state
52
+ self.image_module = image_module
53
+ self.fps = max(1, int(fps))
54
+ self.jpeg_quality = jpeg_quality
55
+ self.render_mode = render_mode.lower()
56
+ self.scene_lock = scene_lock or threading.Lock()
57
+ self.before_render = before_render
58
+ self.fallback_frame = fallback_frame
59
+ self.error_frame = error_frame
60
+
61
+ self.renderer_error: str | None = None
62
+ self._latest_jpeg: bytes = b""
63
+ self._camera_lock = threading.Lock()
64
+ self._running = True
65
+ self._thread = threading.Thread(target=self._run, name="simrig-frame-pump", daemon=True)
66
+ self._thread.start()
67
+
68
+ def set_camera_from_query(self, query: dict[str, list[str]]) -> None:
69
+ with self._camera_lock:
70
+ self.camera_state.update_from_query(query)
71
+ self.camera_state.apply(self.camera)
72
+
73
+ def get_jpeg(self) -> bytes:
74
+ return self._latest_jpeg
75
+
76
+ def stats(self) -> dict[str, Any]:
77
+ return {
78
+ "fps_target": self.fps,
79
+ "renderer_error": self.renderer_error,
80
+ "camera": self.camera_state.to_dict(),
81
+ }
82
+
83
+ def close(self) -> None:
84
+ self._running = False
85
+ self._thread.join(timeout=2.0)
86
+
87
+ def _run(self) -> None:
88
+ renderer = None
89
+ interval = 1.0 / self.fps
90
+ placeholder = np.zeros((self.height, self.width, 3), dtype=np.uint8)
91
+ self._latest_jpeg = encode_jpeg(self.image_module, placeholder, quality=self.jpeg_quality)
92
+
93
+ if self.render_mode == "mujoco":
94
+ try:
95
+ renderer = create_mujoco_renderer(
96
+ self.mujoco,
97
+ self.model,
98
+ height=self.height,
99
+ width=self.width,
100
+ )
101
+ except Exception as exc:
102
+ self.renderer_error = str(exc)
103
+
104
+ while self._running:
105
+ started = time.monotonic()
106
+ try:
107
+ with self.scene_lock:
108
+ if self.before_render is not None:
109
+ self.before_render()
110
+ if self.render_mode == "topdown":
111
+ if self.fallback_frame is None:
112
+ raise RuntimeError("topdown mode requires fallback_frame.")
113
+ frame = self.fallback_frame()
114
+ elif renderer is None:
115
+ message = self.renderer_error or "MuJoCo renderer unavailable."
116
+ if self.error_frame is None:
117
+ raise RuntimeError(message)
118
+ frame = self.error_frame(message)
119
+ else:
120
+ with self._camera_lock:
121
+ camera = self.camera_state.apply(self.camera)
122
+ renderer.update_scene(self.mj_data, camera=camera)
123
+ frame = renderer.render()
124
+ self.renderer_error = None
125
+ self._latest_jpeg = encode_jpeg(
126
+ self.image_module,
127
+ frame,
128
+ quality=self.jpeg_quality,
129
+ )
130
+ except Exception as exc:
131
+ self.renderer_error = str(exc)
132
+ if self.error_frame is not None:
133
+ self._latest_jpeg = encode_jpeg(
134
+ self.image_module,
135
+ self.error_frame(str(exc)),
136
+ quality=self.jpeg_quality,
137
+ )
138
+ elapsed = time.monotonic() - started
139
+ time.sleep(max(0.0, interval - elapsed))
140
+
141
+ if renderer is not None:
142
+ try:
143
+ renderer.close()
144
+ except Exception:
145
+ pass
@@ -0,0 +1,130 @@
1
+ """Shared browser viewer HTML/CSS/JS for SimRig localhost tools."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ def viewer_styles(*, sidebar_width: int = 360) -> str:
7
+ return f"""
8
+ :root {{ color-scheme: dark; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }}
9
+ body {{ margin: 0; background: #111; color: #eee; display: grid; grid-template-columns: 1fr {sidebar_width}px; min-height: 100vh; }}
10
+ main {{ display: grid; place-items: stretch; background: #050505; position: relative; }}
11
+ #viewport {{ position: relative; width: 100%; height: 100vh; }}
12
+ #frame {{ width: 100%; height: 100%; object-fit: contain; background: #000; display: block; cursor: grab; user-select: none; touch-action: none; }}
13
+ #frame.dragging {{ cursor: grabbing; }}
14
+ #hint {{ position: absolute; left: 16px; bottom: 16px; color: #aaa; font-size: 12px; background: rgba(0,0,0,0.55); padding: 8px 10px; border-radius: 6px; pointer-events: none; }}
15
+ aside {{ padding: 16px; border-left: 1px solid #333; background: #181818; overflow: auto; max-height: 100vh; }}
16
+ h1 {{ font-size: 18px; margin: 0 0 12px; }}
17
+ label {{ display: block; margin: 10px 0 4px; color: #bbb; font-size: 13px; }}
18
+ input {{ width: 100%; box-sizing: border-box; background: #0d0d0d; color: #eee; border: 1px solid #444; padding: 8px; }}
19
+ button {{ margin: 8px 6px 0 0; padding: 8px 10px; background: #2d6cdf; color: white; border: 0; cursor: pointer; }}
20
+ button.secondary {{ background: #333; }}
21
+ pre {{ white-space: pre-wrap; background: #0d0d0d; border: 1px solid #333; padding: 10px; font-size: 12px; }}
22
+ .meta {{ color: #aaa; font-size: 12px; margin-bottom: 12px; }}
23
+ .control {{ margin: 0 0 14px; padding-bottom: 10px; border-bottom: 1px solid #2a2a2a; }}
24
+ .control label {{ margin-top: 0; }}
25
+ .control input[type=range] {{ width: 100%; }}
26
+ .value {{ color: #8ab4ff; font-size: 12px; margin-top: 4px; }}
27
+ """
28
+
29
+
30
+ def camera_interaction_script() -> str:
31
+ return """
32
+ let cameraAzimuth = 135;
33
+ let cameraElevation = -20;
34
+ let cameraDistance = 2.4;
35
+ let cameraInteractive = true;
36
+ let dragging = false;
37
+ let lastX = 0;
38
+ let lastY = 0;
39
+ let cameraTimer = null;
40
+
41
+ function clamp(value, min, max) {
42
+ return Math.max(min, Math.min(max, value));
43
+ }
44
+
45
+ async function pushCamera() {
46
+ if (!cameraInteractive) return;
47
+ const params = new URLSearchParams({
48
+ azimuth: String(cameraAzimuth),
49
+ elevation: String(cameraElevation),
50
+ distance: String(cameraDistance),
51
+ });
52
+ try {
53
+ await fetch('/camera?' + params.toString(), {cache: 'no-store'});
54
+ } catch (err) {
55
+ console.warn('camera update failed', err);
56
+ }
57
+ }
58
+
59
+ function scheduleCameraPush() {
60
+ clearTimeout(cameraTimer);
61
+ cameraTimer = setTimeout(pushCamera, 30);
62
+ }
63
+
64
+ function bindCameraControls(frame) {
65
+ frame.addEventListener('mousedown', (event) => {
66
+ if (!cameraInteractive || event.button !== 0) return;
67
+ dragging = true;
68
+ frame.classList.add('dragging');
69
+ lastX = event.clientX;
70
+ lastY = event.clientY;
71
+ });
72
+ window.addEventListener('mouseup', () => {
73
+ if (!dragging) return;
74
+ dragging = false;
75
+ frame.classList.remove('dragging');
76
+ pushCamera();
77
+ });
78
+ window.addEventListener('mousemove', (event) => {
79
+ if (!dragging || !cameraInteractive) return;
80
+ const dx = event.clientX - lastX;
81
+ const dy = event.clientY - lastY;
82
+ lastX = event.clientX;
83
+ lastY = event.clientY;
84
+ cameraAzimuth = (cameraAzimuth + dx * 0.35) % 360;
85
+ cameraElevation = clamp(cameraElevation - dy * 0.25, -89, 89);
86
+ scheduleCameraPush();
87
+ });
88
+ frame.addEventListener('wheel', (event) => {
89
+ if (!cameraInteractive) return;
90
+ event.preventDefault();
91
+ const scale = event.deltaY > 0 ? 1.08 : 0.92;
92
+ cameraDistance = clamp(cameraDistance * scale, 0.4, 12.0);
93
+ scheduleCameraPush();
94
+ }, {passive: false});
95
+ }
96
+
97
+ function applyCameraFromStatus(status) {
98
+ if (!status || !status.camera) return;
99
+ cameraAzimuth = status.camera.azimuth ?? cameraAzimuth;
100
+ cameraElevation = status.camera.elevation ?? cameraElevation;
101
+ cameraDistance = status.camera.distance ?? cameraDistance;
102
+ cameraInteractive = status.camera.interactive !== false;
103
+ }
104
+ """
105
+
106
+
107
+ def frame_poll_script(*, poll_ms: int = 33) -> str:
108
+ return f"""
109
+ let frameTimer = null;
110
+ let frameUrl = null;
111
+
112
+ async function refreshFrame() {{
113
+ clearTimeout(frameTimer);
114
+ try {{
115
+ const res = await fetch('/frame.jpg?t=' + Date.now(), {{cache: 'no-store'}});
116
+ if (!res.ok) throw new Error('frame request failed');
117
+ const blob = await res.blob();
118
+ const url = URL.createObjectURL(blob);
119
+ frame.onload = () => {{
120
+ if (frameUrl) URL.revokeObjectURL(frameUrl);
121
+ frameUrl = url;
122
+ }};
123
+ frame.src = url;
124
+ }} catch (err) {{
125
+ console.warn('frame refresh failed', err);
126
+ }} finally {{
127
+ frameTimer = setTimeout(refreshFrame, {poll_ms});
128
+ }}
129
+ }}
130
+ """