world-engine-client 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/PKG-INFO +32 -7
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/README.md +27 -6
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/pyproject.toml +4 -1
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/src/world_engine_client/__init__.py +3 -1
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/src/world_engine_client/env.py +106 -28
- world_engine_client-0.4.0/src/world_engine_client/render.py +373 -0
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/src/world_engine_client/rotations.py +2 -3
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/tests/conftest.py +18 -23
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/tests/test_env_transport.py +1 -45
- world_engine_client-0.4.0/tests/test_render.py +175 -0
- world_engine_client-0.3.0/src/world_engine_client/wire.py +0 -66
- world_engine_client-0.3.0/tests/test_wire.py +0 -140
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/.github/workflows/publish.yml +0 -0
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/.github/workflows/test.yml +0 -0
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/.gitignore +0 -0
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/LICENSE +0 -0
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/src/world_engine_client/errors.py +0 -0
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/src/world_engine_client/gym_adapter.py +0 -0
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/src/world_engine_client/spaces.py +0 -0
- {world_engine_client-0.3.0 → world_engine_client-0.4.0}/tests/test_spaces.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: world-engine-client
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Remote Gym-style Python client for a World Engine simulation server: connect(token) -> reset/step over HTTP.
|
|
5
5
|
Project-URL: Homepage, https://github.com/Grounded-Intelligence-Initiative/world-engine-client-python
|
|
6
6
|
Project-URL: Repository, https://github.com/Grounded-Intelligence-Initiative/world-engine-client-python
|
|
@@ -22,6 +22,10 @@ Provides-Extra: gym
|
|
|
22
22
|
Requires-Dist: gymnasium>=1.0; extra == 'gym'
|
|
23
23
|
Provides-Extra: img
|
|
24
24
|
Requires-Dist: pillow>=10; extra == 'img'
|
|
25
|
+
Provides-Extra: render
|
|
26
|
+
Requires-Dist: pillow>=10; extra == 'render'
|
|
27
|
+
Requires-Dist: playwright>=1.40; extra == 'render'
|
|
28
|
+
Requires-Dist: websockets>=12; extra == 'render'
|
|
25
29
|
Description-Content-Type: text/markdown
|
|
26
30
|
|
|
27
31
|
# world-engine-client
|
|
@@ -84,11 +88,32 @@ cookies and editor write credentials are not Python control credentials.
|
|
|
84
88
|
|
|
85
89
|
Protocol 3 is the default. Direct connections remain reset-required and state-only
|
|
86
90
|
unless requested otherwise. `proto=2` preserves state-only compatibility and
|
|
87
|
-
rejects image/resume requests.
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
rejects image/resume requests. `images=True` captures all configured cameras;
|
|
92
|
+
`cameras=['wrist']` selects a subset. RGB arrays appear in `obs['rgb']`; `render()`
|
|
93
|
+
returns the latest camera without stepping. `as_gymnasium()` supplies an optional
|
|
94
|
+
Gymnasium Env.
|
|
95
|
+
|
|
96
|
+
### Camera images
|
|
97
|
+
|
|
98
|
+
Camera images are drawn on your computer, never on the server: the server only
|
|
99
|
+
simulates physics and sends the poses with each observation, and a headless Chrome
|
|
100
|
+
here runs the same scene the browser viewer shows. Nothing is throttled when no
|
|
101
|
+
browser tab is open, and the images never travel over the network. This needs the
|
|
102
|
+
`render` extra:
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
pip install 'world-engine-client[render]'
|
|
106
|
+
python -m playwright install chromium # skip if Google Chrome is installed
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Scene assets are fetched once per session and cached by content hash in a
|
|
110
|
+
temporary directory that `close()` deletes; pass
|
|
111
|
+
`render_options={"cache_dir": "..."}` to keep them across sessions. Chrome must
|
|
112
|
+
get hardware WebGL (check `chrome://gpu`). Without it the renderer refuses to
|
|
113
|
+
start, because software rendering manages only a few frames per second;
|
|
114
|
+
`render_options={"allow_software": True}` accepts that, and the
|
|
115
|
+
`WE_RENDER_CHROME_ARGS` environment variable overrides Chrome's flags.
|
|
116
|
+
`env.last_timings` splits each step into server, transport and render time.
|
|
92
117
|
|
|
93
118
|
Heartbeats run every 30 seconds with a 90-second controller lease. Expiry freezes
|
|
94
119
|
the simulation until explicit takeover. A lost action response is never retried:
|
|
@@ -166,7 +191,7 @@ R = rot6d_to_matrix(demo["action_dict/abs_rot_6d"][t])
|
|
|
166
191
|
## Development
|
|
167
192
|
|
|
168
193
|
```bash
|
|
169
|
-
pip install -e '.[img,gym]' pytest && python -m pytest
|
|
194
|
+
pip install -e '.[img,gym,render]' pytest && python -m pytest
|
|
170
195
|
```
|
|
171
196
|
|
|
172
197
|
`tests/` covers the client alone against a stub server: binary decoding, the
|
|
@@ -58,11 +58,32 @@ cookies and editor write credentials are not Python control credentials.
|
|
|
58
58
|
|
|
59
59
|
Protocol 3 is the default. Direct connections remain reset-required and state-only
|
|
60
60
|
unless requested otherwise. `proto=2` preserves state-only compatibility and
|
|
61
|
-
rejects image/resume requests.
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
61
|
+
rejects image/resume requests. `images=True` captures all configured cameras;
|
|
62
|
+
`cameras=['wrist']` selects a subset. RGB arrays appear in `obs['rgb']`; `render()`
|
|
63
|
+
returns the latest camera without stepping. `as_gymnasium()` supplies an optional
|
|
64
|
+
Gymnasium Env.
|
|
65
|
+
|
|
66
|
+
### Camera images
|
|
67
|
+
|
|
68
|
+
Camera images are drawn on your computer, never on the server: the server only
|
|
69
|
+
simulates physics and sends the poses with each observation, and a headless Chrome
|
|
70
|
+
here runs the same scene the browser viewer shows. Nothing is throttled when no
|
|
71
|
+
browser tab is open, and the images never travel over the network. This needs the
|
|
72
|
+
`render` extra:
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install 'world-engine-client[render]'
|
|
76
|
+
python -m playwright install chromium # skip if Google Chrome is installed
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
Scene assets are fetched once per session and cached by content hash in a
|
|
80
|
+
temporary directory that `close()` deletes; pass
|
|
81
|
+
`render_options={"cache_dir": "..."}` to keep them across sessions. Chrome must
|
|
82
|
+
get hardware WebGL (check `chrome://gpu`). Without it the renderer refuses to
|
|
83
|
+
start, because software rendering manages only a few frames per second;
|
|
84
|
+
`render_options={"allow_software": True}` accepts that, and the
|
|
85
|
+
`WE_RENDER_CHROME_ARGS` environment variable overrides Chrome's flags.
|
|
86
|
+
`env.last_timings` splits each step into server, transport and render time.
|
|
66
87
|
|
|
67
88
|
Heartbeats run every 30 seconds with a 90-second controller lease. Expiry freezes
|
|
68
89
|
the simulation until explicit takeover. A lost action response is never retried:
|
|
@@ -140,7 +161,7 @@ R = rot6d_to_matrix(demo["action_dict/abs_rot_6d"][t])
|
|
|
140
161
|
## Development
|
|
141
162
|
|
|
142
163
|
```bash
|
|
143
|
-
pip install -e '.[img,gym]' pytest && python -m pytest
|
|
164
|
+
pip install -e '.[img,gym,render]' pytest && python -m pytest
|
|
144
165
|
```
|
|
145
166
|
|
|
146
167
|
`tests/` covers the client alone against a stub server: binary decoding, the
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "world-engine-client"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Remote Gym-style Python client for a World Engine simulation server: connect(token) -> reset/step over HTTP."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -28,6 +28,9 @@ dependencies = [
|
|
|
28
28
|
[project.optional-dependencies]
|
|
29
29
|
img = ["Pillow>=10"]
|
|
30
30
|
gym = ["gymnasium>=1.0"]
|
|
31
|
+
# Camera images, rendered on this machine; also run
|
|
32
|
+
# `python -m playwright install chromium` unless Chrome is installed.
|
|
33
|
+
render = ["Pillow>=10", "playwright>=1.40", "websockets>=12"]
|
|
31
34
|
|
|
32
35
|
[dependency-groups]
|
|
33
36
|
dev = ["pytest>=8", "ruff", "Pillow>=10", "gymnasium>=1.0"]
|
|
@@ -30,6 +30,7 @@ from .errors import (
|
|
|
30
30
|
TokenError,
|
|
31
31
|
WorldEngineClientError,
|
|
32
32
|
)
|
|
33
|
+
from .render import RendererError
|
|
33
34
|
from .rotations import (
|
|
34
35
|
axisangle_to_matrix,
|
|
35
36
|
matrix_to_axisangle,
|
|
@@ -49,9 +50,10 @@ __all__ = [
|
|
|
49
50
|
"BadConfigError",
|
|
50
51
|
"RecoveryRequiredError",
|
|
51
52
|
"RecordingPendingError",
|
|
53
|
+
"RendererError",
|
|
52
54
|
"matrix_to_rot6d",
|
|
53
55
|
"rot6d_to_matrix",
|
|
54
56
|
"axisangle_to_matrix",
|
|
55
57
|
"matrix_to_axisangle",
|
|
56
58
|
]
|
|
57
|
-
__version__ = "0.
|
|
59
|
+
__version__ = "0.4.0"
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
Synchronous by design — an inference loop is synchronous. Transport is plain
|
|
4
4
|
HTTP POST (persistent stdlib http.client): one round-trip per step, which the lockstep
|
|
5
5
|
semantics require anyway. Obs arrays come back as JSON lists and are converted
|
|
6
|
-
to numpy here
|
|
6
|
+
to numpy here. Camera images are rendered on this machine (see render.py): the
|
|
7
|
+
server sends the poses they are drawn from with every observation.
|
|
7
8
|
|
|
8
9
|
Proto 2 (server ≥ the editor-teleop release): the default control mode is
|
|
9
10
|
``osc_pose_delta`` — a 7-dim action ``[dpos(3), drot_axisangle(3), gripper]``
|
|
@@ -26,7 +27,8 @@ import urllib.parse
|
|
|
26
27
|
import numpy as np
|
|
27
28
|
|
|
28
29
|
from .errors import ProtocolError, RecoveryRequiredError, WorldEngineClientError, raise_for
|
|
29
|
-
|
|
30
|
+
|
|
31
|
+
MAX_RESPONSE = 64 * 1024 * 1024
|
|
30
32
|
|
|
31
33
|
_PROTO = 3
|
|
32
34
|
|
|
@@ -45,6 +47,8 @@ def connect(
|
|
|
45
47
|
expected_instance_id: str | None = None,
|
|
46
48
|
proto: int = _PROTO,
|
|
47
49
|
access_provider=None,
|
|
50
|
+
render: str = "auto",
|
|
51
|
+
render_options: dict | None = None,
|
|
48
52
|
) -> RemoteEnv:
|
|
49
53
|
"""Bind to the server as THE controller and return a ready RemoteEnv.
|
|
50
54
|
|
|
@@ -52,9 +56,20 @@ def connect(
|
|
|
52
56
|
shown in the editor Inspector's Python API card during a Simulate session).
|
|
53
57
|
``takeover=True`` revokes a controller that died without releasing.
|
|
54
58
|
``control_mode`` / ``obs_keys`` negotiate the space at connect time (see
|
|
55
|
-
``env.metadata['supported_control_modes']`` for what the server offers).
|
|
59
|
+
``env.metadata['supported_control_modes']`` for what the server offers).
|
|
60
|
+
|
|
61
|
+
Camera images (``images=True`` or ``cameras=[...]``) are rendered on this
|
|
62
|
+
machine in headless Chrome; the server only simulates. That needs the
|
|
63
|
+
``render`` extra. ``render`` accepts ``"auto"`` or ``"local"`` (the same
|
|
64
|
+
thing; kept for callers that pass it). ``render_options`` go to
|
|
65
|
+
:class:`world_engine_client.render.LocalRenderer` (``cache_dir``,
|
|
66
|
+
``allow_software``, ``chrome_args``)."""
|
|
67
|
+
if render not in ("auto", "local"):
|
|
68
|
+
raise ValueError("render must be 'auto' or 'local': servers no longer render camera images")
|
|
56
69
|
env = RemoteEnv(url.rstrip("/"), token, timeout=timeout)
|
|
57
70
|
env._access_provider = access_provider
|
|
71
|
+
env._render_request = render
|
|
72
|
+
env._render_options = dict(render_options or {})
|
|
58
73
|
try:
|
|
59
74
|
env._connect(
|
|
60
75
|
takeover=takeover,
|
|
@@ -66,8 +81,8 @@ def connect(
|
|
|
66
81
|
expected_instance_id=expected_instance_id,
|
|
67
82
|
proto=proto,
|
|
68
83
|
)
|
|
69
|
-
except
|
|
70
|
-
env.
|
|
84
|
+
except BaseException:
|
|
85
|
+
env.close()
|
|
71
86
|
raise
|
|
72
87
|
return env
|
|
73
88
|
|
|
@@ -105,6 +120,9 @@ class RemoteEnv:
|
|
|
105
120
|
self.action_dim: int = 7
|
|
106
121
|
self.observation_keys: list[str] = []
|
|
107
122
|
self.control_mode: str | None = None
|
|
123
|
+
self._render_request = "server"
|
|
124
|
+
self._render_options: dict = {}
|
|
125
|
+
self.renderer = None # LocalRenderer when images are rendered on this machine
|
|
108
126
|
|
|
109
127
|
# ----- Gym API ------------------------------------------------------------ #
|
|
110
128
|
def reset(self, *, seed: int | None = None, options: dict | None = None):
|
|
@@ -204,6 +222,9 @@ class RemoteEnv:
|
|
|
204
222
|
self._stop.set()
|
|
205
223
|
if self._heartbeat_thread is not None:
|
|
206
224
|
self._heartbeat_thread.join(timeout=self._timeout + 1)
|
|
225
|
+
if self.renderer is not None:
|
|
226
|
+
self.renderer.close()
|
|
227
|
+
self.renderer = None
|
|
207
228
|
if self._session_id is None:
|
|
208
229
|
self._http.close()
|
|
209
230
|
return
|
|
@@ -233,11 +254,16 @@ class RemoteEnv:
|
|
|
233
254
|
expected_instance_id=None,
|
|
234
255
|
proto=_PROTO,
|
|
235
256
|
) -> None:
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
257
|
+
local = bool(images or cameras)
|
|
258
|
+
if local:
|
|
259
|
+
from . import render as local_render
|
|
260
|
+
|
|
261
|
+
if not local_render.available():
|
|
262
|
+
raise local_render.RendererError(
|
|
263
|
+
"camera images are rendered on this machine and need the render extra",
|
|
264
|
+
code="renderer_unavailable",
|
|
265
|
+
fixes=local_render._INSTALL_FIXES,
|
|
266
|
+
)
|
|
241
267
|
body: dict = {
|
|
242
268
|
"proto": proto,
|
|
243
269
|
"takeover": takeover,
|
|
@@ -250,6 +276,8 @@ class RemoteEnv:
|
|
|
250
276
|
body["control_mode"] = control_mode
|
|
251
277
|
if obs_keys is not None:
|
|
252
278
|
body["obs_keys"] = list(obs_keys)
|
|
279
|
+
if local:
|
|
280
|
+
body["render"] = "client"
|
|
253
281
|
reply = self._post("/control/connect", body, with_session=False)
|
|
254
282
|
if reply.get("proto") != proto:
|
|
255
283
|
raise ProtocolError("server did not honor the requested protocol")
|
|
@@ -259,6 +287,17 @@ class RemoteEnv:
|
|
|
259
287
|
if proto == 3:
|
|
260
288
|
self._episode_id = reply.get("episode_id")
|
|
261
289
|
self._refresh_env(reply.get("env"))
|
|
290
|
+
bundle = reply.get("render") if local else None
|
|
291
|
+
if local and (not bundle or bundle.get("protocol") != 1):
|
|
292
|
+
raise ProtocolError(
|
|
293
|
+
"this server predates client rendering; upgrade it, or connect with images=False"
|
|
294
|
+
)
|
|
295
|
+
if local:
|
|
296
|
+
from .render import LocalRenderer
|
|
297
|
+
|
|
298
|
+
self.renderer = LocalRenderer(
|
|
299
|
+
self._open_render, bundle["scene_init"], bundle["digests"], **self._render_options
|
|
300
|
+
)
|
|
262
301
|
if proto == 3:
|
|
263
302
|
self._heartbeat_thread = threading.Thread(target=self._heartbeat, daemon=True)
|
|
264
303
|
self._heartbeat_thread.start()
|
|
@@ -303,19 +342,7 @@ class RemoteEnv:
|
|
|
303
342
|
if len(data) > MAX_RESPONSE:
|
|
304
343
|
raise ProtocolError("response exceeds size limit")
|
|
305
344
|
received = time.perf_counter()
|
|
306
|
-
|
|
307
|
-
reply = decode(data, self.metadata.get("cameras", {}))
|
|
308
|
-
decoded = time.perf_counter()
|
|
309
|
-
server_ms = reply.get("info", {}).get("timings", {}).get("server_ms", 0)
|
|
310
|
-
self.last_timings = {
|
|
311
|
-
**reply.get("info", {}).get("timings", {}),
|
|
312
|
-
"decode_ms": (decoded - received) * 1000,
|
|
313
|
-
"transport_ms": max(0, (received - started) * 1000 - server_ms),
|
|
314
|
-
"total_ms": (decoded - started) * 1000,
|
|
315
|
-
"response_bytes": len(data),
|
|
316
|
-
}
|
|
317
|
-
else:
|
|
318
|
-
reply = json.loads(data)
|
|
345
|
+
reply = json.loads(data)
|
|
319
346
|
if not isinstance(reply, dict) or "ok" not in reply:
|
|
320
347
|
raise ProtocolError("invalid response envelope")
|
|
321
348
|
except (OSError, http.client.HTTPException, ValueError, ProtocolError) as exc:
|
|
@@ -328,11 +355,8 @@ class RemoteEnv:
|
|
|
328
355
|
raise ProtocolError(f"request failed at {path}: {exc}") from exc
|
|
329
356
|
if reply.get("ok") and path in ("/control/reset", "/control/step", "/control/observe"):
|
|
330
357
|
try:
|
|
331
|
-
if
|
|
332
|
-
|
|
333
|
-
and response.getheader("Content-Type", "").split(";")[0] != CONTENT_TYPE
|
|
334
|
-
):
|
|
335
|
-
raise ValueError("image observation silently downgraded to JSON")
|
|
358
|
+
if self.renderer is not None and "render_state" not in reply:
|
|
359
|
+
raise ValueError("observation without the poses to render")
|
|
336
360
|
if not isinstance(reply["obs"], dict) or not isinstance(reply["info"], dict):
|
|
337
361
|
raise ValueError("invalid observation or info")
|
|
338
362
|
for key, spec in (
|
|
@@ -362,7 +386,45 @@ class RemoteEnv:
|
|
|
362
386
|
if reply.get("code") == "recovery_required":
|
|
363
387
|
self._uncertain = True
|
|
364
388
|
raise_for(reply)
|
|
389
|
+
if path not in ("/control/reset", "/control/step", "/control/observe"):
|
|
365
390
|
return reply
|
|
391
|
+
server_ms = reply["info"].get("timings", {}).get("server_ms", 0)
|
|
392
|
+
timings = {
|
|
393
|
+
"server_ms": server_ms,
|
|
394
|
+
"transport_ms": max(0, (received - started) * 1000 - server_ms),
|
|
395
|
+
"response_bytes": len(data),
|
|
396
|
+
}
|
|
397
|
+
if self.renderer is not None:
|
|
398
|
+
# Outside the lock: the heartbeat must not wait for a frame. A
|
|
399
|
+
# render failure leaves physics as stepped; observe() renders again.
|
|
400
|
+
rendering = time.perf_counter()
|
|
401
|
+
reply["obs"]["rgb"] = self.renderer.render(reply["render_state"])
|
|
402
|
+
timings["render_ms"] = (time.perf_counter() - rendering) * 1000
|
|
403
|
+
timings["total_ms"] = (time.perf_counter() - started) * 1000
|
|
404
|
+
self.last_timings = timings
|
|
405
|
+
return reply
|
|
406
|
+
|
|
407
|
+
def _open_render(self, path: str):
|
|
408
|
+
"""GET <server>/render<path> with this session's credentials, on its own
|
|
409
|
+
connection (the renderer runs on another thread)."""
|
|
410
|
+
parsed = urllib.parse.urlsplit(self._base)
|
|
411
|
+
transport = (
|
|
412
|
+
http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
|
|
413
|
+
)
|
|
414
|
+
conn = transport(parsed.hostname, parsed.port, timeout=max(self._timeout, 120))
|
|
415
|
+
headers = {"X-We-Control-Token": self._token}
|
|
416
|
+
if self._access_provider:
|
|
417
|
+
headers["Authorization"] = "Bearer " + self._access_provider()
|
|
418
|
+
conn.request("GET", self._prefix + "/render" + path, headers=headers)
|
|
419
|
+
response = conn.getresponse()
|
|
420
|
+
if response.status == 404:
|
|
421
|
+
conn.close()
|
|
422
|
+
raise FileNotFoundError(path)
|
|
423
|
+
if response.status != 200:
|
|
424
|
+
detail = response.read(500).decode("utf-8", "replace")
|
|
425
|
+
conn.close()
|
|
426
|
+
raise OSError(f"HTTP {response.status} for {path}: {detail}")
|
|
427
|
+
return _Owned(response, conn)
|
|
366
428
|
|
|
367
429
|
@staticmethod
|
|
368
430
|
def _np_obs(obs: dict) -> dict:
|
|
@@ -377,3 +439,19 @@ class RemoteEnv:
|
|
|
377
439
|
else:
|
|
378
440
|
out[k] = v
|
|
379
441
|
return out
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
class _Owned:
|
|
445
|
+
"""A response that closes its connection when the caller is done."""
|
|
446
|
+
|
|
447
|
+
def __init__(self, response, conn):
|
|
448
|
+
self._response, self._conn = response, conn
|
|
449
|
+
|
|
450
|
+
def read(self, *a):
|
|
451
|
+
return self._response.read(*a)
|
|
452
|
+
|
|
453
|
+
def __enter__(self):
|
|
454
|
+
return self
|
|
455
|
+
|
|
456
|
+
def __exit__(self, *exc):
|
|
457
|
+
self._conn.close()
|
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
"""Render camera observations on this machine (``render="local"``).
|
|
2
|
+
|
|
3
|
+
The server only simulates physics. It sends the scene description once at
|
|
4
|
+
connect and the poses with every observation; a headless Chrome started here
|
|
5
|
+
runs the same capture page the server-side renderer runs, so the images match.
|
|
6
|
+
Headless Chrome is never a "background tab", so nothing throttles it.
|
|
7
|
+
|
|
8
|
+
The capture page, its scripts and the scene assets are fetched from the
|
|
9
|
+
server's ``/render/`` prefix with this session's credentials. Assets are cached
|
|
10
|
+
by content digest in a temporary directory that ``close()`` deletes, or in
|
|
11
|
+
``cache_dir`` to keep them across sessions.
|
|
12
|
+
|
|
13
|
+
Needs the ``render`` extra (Playwright, websockets, Pillow) and a Chrome or
|
|
14
|
+
Chromium: ``pip install 'world-engine-client[render]'`` then
|
|
15
|
+
``python -m playwright install chromium`` (or use an installed Chrome).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import asyncio
|
|
21
|
+
import hashlib
|
|
22
|
+
import io
|
|
23
|
+
import json
|
|
24
|
+
import mimetypes
|
|
25
|
+
import os
|
|
26
|
+
import shlex
|
|
27
|
+
import shutil
|
|
28
|
+
import sys
|
|
29
|
+
import tempfile
|
|
30
|
+
import threading
|
|
31
|
+
import warnings
|
|
32
|
+
from collections.abc import Callable
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
|
|
37
|
+
from .errors import WorldEngineClientError
|
|
38
|
+
|
|
39
|
+
# Hardware WebGL in headless Chrome, per platform. The Linux flags are the ones
|
|
40
|
+
# the server renderer is verified with (NVIDIA through Vulkan ANGLE); macOS uses
|
|
41
|
+
# Metal; Windows uses Chrome's default (D3D11). WE_RENDER_CHROME_ARGS overrides.
|
|
42
|
+
if sys.platform == "darwin":
|
|
43
|
+
_CHROME_ARGS = ["--headless=new", "--use-angle=metal", "--ignore-gpu-blocklist"]
|
|
44
|
+
elif sys.platform.startswith("linux"):
|
|
45
|
+
_CHROME_ARGS = [
|
|
46
|
+
"--headless=new",
|
|
47
|
+
"--ozone-platform=headless",
|
|
48
|
+
"--use-angle=vulkan",
|
|
49
|
+
"--enable-features=Vulkan",
|
|
50
|
+
"--use-gl=angle",
|
|
51
|
+
"--ignore-gpu-blocklist",
|
|
52
|
+
"--no-sandbox",
|
|
53
|
+
]
|
|
54
|
+
else:
|
|
55
|
+
_CHROME_ARGS = ["--headless=new", "--ignore-gpu-blocklist"]
|
|
56
|
+
|
|
57
|
+
_INSTALL_FIXES = [
|
|
58
|
+
"pip install 'world-engine-client[render]' && python -m playwright install chromium",
|
|
59
|
+
"or pass images=False for a state-only session",
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class RendererError(WorldEngineClientError):
|
|
64
|
+
"""Local rendering is unavailable or failed. The physics step itself is
|
|
65
|
+
unaffected: ``env.observe()`` renders the current state again."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def available() -> bool:
|
|
69
|
+
"""True when the ``render`` extra is installed (Chrome is checked on start)."""
|
|
70
|
+
import importlib.util
|
|
71
|
+
|
|
72
|
+
return all(importlib.util.find_spec(m) is not None for m in ("playwright", "websockets", "PIL"))
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
Fetch = Callable[[str], "io.BufferedIOBase"]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class LocalRenderer:
|
|
79
|
+
"""Headless Chrome running the capture page; ``render(state)`` -> images.
|
|
80
|
+
|
|
81
|
+
``fetch(path)`` opens ``<server>/render<path>`` with the session's
|
|
82
|
+
credentials and returns a readable response."""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
fetch: Fetch,
|
|
87
|
+
scene_init: dict,
|
|
88
|
+
digests: dict[str, str],
|
|
89
|
+
*,
|
|
90
|
+
cache_dir: str | os.PathLike | None = None,
|
|
91
|
+
allow_software: bool = False,
|
|
92
|
+
chrome_args: list[str] | None = None,
|
|
93
|
+
timeout: float = 300.0,
|
|
94
|
+
):
|
|
95
|
+
if not available():
|
|
96
|
+
raise RendererError(
|
|
97
|
+
"local rendering needs the render extra",
|
|
98
|
+
code="renderer_unavailable",
|
|
99
|
+
fixes=_INSTALL_FIXES,
|
|
100
|
+
)
|
|
101
|
+
self._fetch = fetch
|
|
102
|
+
self._scene_init = scene_init
|
|
103
|
+
self._digests = dict(digests)
|
|
104
|
+
self._own_cache = cache_dir is None
|
|
105
|
+
self._cache = Path(cache_dir or tempfile.mkdtemp(prefix="we_render_"))
|
|
106
|
+
self._cache.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
self._allow_software = allow_software
|
|
108
|
+
override = os.environ.get("WE_RENDER_CHROME_ARGS")
|
|
109
|
+
self._args = chrome_args or (shlex.split(override) if override else _CHROME_ARGS)
|
|
110
|
+
self._timeout = timeout
|
|
111
|
+
self.gpu = "(unknown)"
|
|
112
|
+
self.software = False
|
|
113
|
+
self.lighting: str | None = None
|
|
114
|
+
self._frame_id = 0
|
|
115
|
+
self._pending: dict[int, asyncio.Future] = {}
|
|
116
|
+
self._ws = None
|
|
117
|
+
self._ready = None
|
|
118
|
+
self._page_error: dict | None = None
|
|
119
|
+
self._console: list[str] = []
|
|
120
|
+
self._http = None
|
|
121
|
+
self._download_lock = threading.Lock()
|
|
122
|
+
self._loop = asyncio.new_event_loop()
|
|
123
|
+
self._thread = threading.Thread(target=self._loop.run_forever, daemon=True)
|
|
124
|
+
self._thread.start()
|
|
125
|
+
try:
|
|
126
|
+
self._call(self._start(), timeout + 30)
|
|
127
|
+
except BaseException:
|
|
128
|
+
self.close()
|
|
129
|
+
raise
|
|
130
|
+
|
|
131
|
+
# ----- public ------------------------------------------------------------- #
|
|
132
|
+
def render(self, state: dict) -> dict[str, np.ndarray]:
|
|
133
|
+
"""Pose the scene as ``state`` (a ``render_state`` from the server) and
|
|
134
|
+
return ``{camera name: (H, W, 3) uint8}``."""
|
|
135
|
+
if self._ws is None:
|
|
136
|
+
raise RendererError("renderer is closed", code="renderer_unavailable")
|
|
137
|
+
header, payload = self._call(self._render(dict(state)), self._timeout)
|
|
138
|
+
from PIL import Image
|
|
139
|
+
|
|
140
|
+
images = {}
|
|
141
|
+
for spec in header.get("images", []):
|
|
142
|
+
chunk = payload[spec["offset"] : spec["offset"] + spec["length"]]
|
|
143
|
+
with Image.open(io.BytesIO(chunk)) as img:
|
|
144
|
+
images[spec["name"]] = np.asarray(img.convert("RGB"), dtype=np.uint8).copy()
|
|
145
|
+
self.last_timings = header.get("timings", {})
|
|
146
|
+
return images
|
|
147
|
+
|
|
148
|
+
def close(self) -> None:
|
|
149
|
+
if self._loop.is_running():
|
|
150
|
+
try:
|
|
151
|
+
self._call(self._stop(), 30)
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
155
|
+
self._thread.join(timeout=10)
|
|
156
|
+
if self._own_cache:
|
|
157
|
+
shutil.rmtree(self._cache, ignore_errors=True)
|
|
158
|
+
|
|
159
|
+
def __enter__(self) -> LocalRenderer:
|
|
160
|
+
return self
|
|
161
|
+
|
|
162
|
+
def __exit__(self, *exc) -> None:
|
|
163
|
+
self.close()
|
|
164
|
+
|
|
165
|
+
# ----- loop thread ---------------------------------------------------------- #
|
|
166
|
+
def _call(self, coro, timeout):
|
|
167
|
+
future = asyncio.run_coroutine_threadsafe(coro, self._loop)
|
|
168
|
+
try:
|
|
169
|
+
return future.result(timeout)
|
|
170
|
+
except TimeoutError as exc:
|
|
171
|
+
future.cancel()
|
|
172
|
+
raise RendererError(
|
|
173
|
+
"local renderer timed out: " + " | ".join(self._console[-5:]),
|
|
174
|
+
code="renderer_unavailable",
|
|
175
|
+
) from exc
|
|
176
|
+
|
|
177
|
+
async def _start(self):
|
|
178
|
+
import websockets
|
|
179
|
+
from playwright.async_api import async_playwright
|
|
180
|
+
|
|
181
|
+
self._ready = asyncio.Event()
|
|
182
|
+
|
|
183
|
+
async def handler(ws):
|
|
184
|
+
self._ws = ws
|
|
185
|
+
async for raw in ws:
|
|
186
|
+
if isinstance(raw, (bytes, bytearray)):
|
|
187
|
+
buf = bytes(raw)
|
|
188
|
+
size = int.from_bytes(buf[:4], "little")
|
|
189
|
+
header = json.loads(buf[4 : 4 + size].decode("utf-8"))
|
|
190
|
+
fut = self._pending.pop(header.get("frame_id"), None)
|
|
191
|
+
if fut is not None and not fut.done():
|
|
192
|
+
fut.set_result((header, buf[4 + size :]))
|
|
193
|
+
continue
|
|
194
|
+
msg = json.loads(raw)
|
|
195
|
+
if msg.get("type") == "ready":
|
|
196
|
+
self.gpu = msg.get("gpu", self.gpu)
|
|
197
|
+
self.software = bool(msg.get("software"))
|
|
198
|
+
self.lighting = msg.get("lighting")
|
|
199
|
+
self._ready.set()
|
|
200
|
+
elif msg.get("type") == "error":
|
|
201
|
+
self._page_error = msg
|
|
202
|
+
self.gpu = msg.get("gpu", self.gpu)
|
|
203
|
+
self._ready.set()
|
|
204
|
+
|
|
205
|
+
self._server = await websockets.serve(handler, "127.0.0.1", 0, max_size=None)
|
|
206
|
+
port = self._server.sockets[0].getsockname()[1]
|
|
207
|
+
self._pw = await async_playwright().start()
|
|
208
|
+
self._browser = await self._launch()
|
|
209
|
+
self._page = await self._browser.new_page()
|
|
210
|
+
self._page.on(
|
|
211
|
+
"console",
|
|
212
|
+
lambda m: m.type in ("warning", "error") and self._console.append(m.text[:300]),
|
|
213
|
+
)
|
|
214
|
+
self._page.on("pageerror", lambda e: self._console.append(f"pageerror: {e}"))
|
|
215
|
+
# A real loopback HTTP server serves the page: Chrome's local network
|
|
216
|
+
# access checks block a page that is not itself on loopback (including
|
|
217
|
+
# one answered by request interception) from opening ws://127.0.0.1.
|
|
218
|
+
self._http = _ProxyServer(self)
|
|
219
|
+
origin = f"http://127.0.0.1:{self._http.port}"
|
|
220
|
+
query = f"captureOnly=1&ws=ws://127.0.0.1:{port}/"
|
|
221
|
+
if self._allow_software:
|
|
222
|
+
query += "&allowSoftware=1"
|
|
223
|
+
await self._page.goto(f"{origin}/index.html?{query}", wait_until="load")
|
|
224
|
+
for _ in range(int(self._timeout * 10)):
|
|
225
|
+
if self._ws is not None:
|
|
226
|
+
break
|
|
227
|
+
await asyncio.sleep(0.1)
|
|
228
|
+
if self._ws is None:
|
|
229
|
+
raise RendererError(
|
|
230
|
+
"the capture page did not start: " + " | ".join(self._console[-5:]),
|
|
231
|
+
code="renderer_unavailable",
|
|
232
|
+
)
|
|
233
|
+
await self._ws.send(json.dumps(self._scene_init))
|
|
234
|
+
try:
|
|
235
|
+
await asyncio.wait_for(self._ready.wait(), self._timeout)
|
|
236
|
+
except TimeoutError as exc:
|
|
237
|
+
raise RendererError(
|
|
238
|
+
"scene loading timed out: " + " | ".join(self._console[-5:]),
|
|
239
|
+
code="renderer_unavailable",
|
|
240
|
+
) from exc
|
|
241
|
+
if self._page_error is not None:
|
|
242
|
+
raise RendererError(
|
|
243
|
+
f"Chrome has no hardware WebGL here ({self.gpu}); software rendering "
|
|
244
|
+
"is very slow, so it is off by default",
|
|
245
|
+
code="software_renderer",
|
|
246
|
+
fixes=[
|
|
247
|
+
"check chrome://gpu, or set WE_RENDER_CHROME_ARGS for your GPU",
|
|
248
|
+
"pass render_options={'allow_software': True} to accept slow rendering",
|
|
249
|
+
"or pass images=False for a state-only session",
|
|
250
|
+
],
|
|
251
|
+
)
|
|
252
|
+
if self.software:
|
|
253
|
+
warnings.warn(
|
|
254
|
+
f"rendering camera images in software ({self.gpu}); expect a few frames per second",
|
|
255
|
+
RuntimeWarning,
|
|
256
|
+
stacklevel=2,
|
|
257
|
+
)
|
|
258
|
+
|
|
259
|
+
async def _launch(self):
|
|
260
|
+
errors = []
|
|
261
|
+
for channel in ("chrome", None): # an installed Chrome, else Playwright's
|
|
262
|
+
try:
|
|
263
|
+
return await self._pw.chromium.launch(
|
|
264
|
+
channel=channel,
|
|
265
|
+
headless=True,
|
|
266
|
+
args=self._args,
|
|
267
|
+
ignore_default_args=["--disable-gpu", "--headless"],
|
|
268
|
+
)
|
|
269
|
+
except Exception as exc: # noqa: BLE001 - try the next browser
|
|
270
|
+
errors.append(str(exc).splitlines()[0])
|
|
271
|
+
raise RendererError(
|
|
272
|
+
"no Chrome or Chromium to render with: " + " | ".join(errors),
|
|
273
|
+
code="renderer_unavailable",
|
|
274
|
+
fixes=_INSTALL_FIXES,
|
|
275
|
+
)
|
|
276
|
+
|
|
277
|
+
async def _render(self, state):
|
|
278
|
+
self._frame_id += 1
|
|
279
|
+
state["frame_id"] = self._frame_id
|
|
280
|
+
fut = self._loop.create_future()
|
|
281
|
+
self._pending[self._frame_id] = fut
|
|
282
|
+
await self._ws.send(json.dumps(state))
|
|
283
|
+
return await fut
|
|
284
|
+
|
|
285
|
+
def _provide(self, path: str) -> tuple[Path | bytes, str]:
|
|
286
|
+
"""The body for ``path`` (a cached file or bytes) and its content type."""
|
|
287
|
+
ctype = mimetypes.guess_type(path)[0] or "application/octet-stream"
|
|
288
|
+
if path.endswith(".js"):
|
|
289
|
+
ctype = "text/javascript"
|
|
290
|
+
digest = self._digests.get(path)
|
|
291
|
+
if digest is None:
|
|
292
|
+
return self._read(path), ctype
|
|
293
|
+
cached = self._cache / digest
|
|
294
|
+
with self._download_lock:
|
|
295
|
+
if not cached.is_file():
|
|
296
|
+
self._download(path, digest, cached)
|
|
297
|
+
return cached, ctype
|
|
298
|
+
|
|
299
|
+
def _read(self, path: str) -> bytes:
|
|
300
|
+
with self._fetch(path) as response:
|
|
301
|
+
return response.read()
|
|
302
|
+
|
|
303
|
+
def _download(self, path: str, digest: str, target: Path) -> None:
|
|
304
|
+
partial = target.with_suffix(".part")
|
|
305
|
+
h = hashlib.sha256()
|
|
306
|
+
with self._fetch(path) as response, open(partial, "wb") as out:
|
|
307
|
+
for chunk in iter(lambda: response.read(1 << 20), b""):
|
|
308
|
+
h.update(chunk)
|
|
309
|
+
out.write(chunk)
|
|
310
|
+
if h.hexdigest() != digest:
|
|
311
|
+
partial.unlink(missing_ok=True)
|
|
312
|
+
raise RendererError(f"asset {path} does not match its digest", code="asset_mismatch")
|
|
313
|
+
partial.replace(target)
|
|
314
|
+
|
|
315
|
+
async def _stop(self):
|
|
316
|
+
for name in ("_browser", "_pw"):
|
|
317
|
+
obj = getattr(self, name, None)
|
|
318
|
+
if obj is not None:
|
|
319
|
+
try:
|
|
320
|
+
await (obj.close() if name == "_browser" else obj.stop())
|
|
321
|
+
except Exception:
|
|
322
|
+
pass
|
|
323
|
+
server = getattr(self, "_server", None)
|
|
324
|
+
if server is not None:
|
|
325
|
+
server.close()
|
|
326
|
+
await server.wait_closed()
|
|
327
|
+
if self._http is not None:
|
|
328
|
+
self._http.stop()
|
|
329
|
+
self._http = None
|
|
330
|
+
self._ws = None
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
class _ProxyServer:
|
|
334
|
+
"""Loopback HTTP server for the capture page: each GET is answered from
|
|
335
|
+
the digest cache or fetched from the server's /render/ prefix."""
|
|
336
|
+
|
|
337
|
+
def __init__(self, renderer: LocalRenderer):
|
|
338
|
+
import http.server
|
|
339
|
+
|
|
340
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
341
|
+
def do_GET(self): # noqa: N802 - http.server API
|
|
342
|
+
path = self.path.split("?", 1)[0]
|
|
343
|
+
try:
|
|
344
|
+
body, ctype = renderer._provide(path)
|
|
345
|
+
except FileNotFoundError:
|
|
346
|
+
self.send_error(404)
|
|
347
|
+
return
|
|
348
|
+
except Exception as exc: # noqa: BLE001 - reported in the console tail
|
|
349
|
+
renderer._console.append(f"fetch {path}: {exc}")
|
|
350
|
+
self.send_error(502)
|
|
351
|
+
return
|
|
352
|
+
size = body.stat().st_size if isinstance(body, Path) else len(body)
|
|
353
|
+
self.send_response(200)
|
|
354
|
+
self.send_header("Content-Type", ctype)
|
|
355
|
+
self.send_header("Content-Length", str(size))
|
|
356
|
+
self.end_headers()
|
|
357
|
+
if isinstance(body, Path):
|
|
358
|
+
with open(body, "rb") as f:
|
|
359
|
+
shutil.copyfileobj(f, self.wfile, 1 << 20)
|
|
360
|
+
else:
|
|
361
|
+
self.wfile.write(body)
|
|
362
|
+
|
|
363
|
+
def log_message(self, *args):
|
|
364
|
+
pass
|
|
365
|
+
|
|
366
|
+
self._httpd = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
367
|
+
self._httpd.daemon_threads = True
|
|
368
|
+
self.port = self._httpd.server_address[1]
|
|
369
|
+
threading.Thread(target=self._httpd.serve_forever, daemon=True).start()
|
|
370
|
+
|
|
371
|
+
def stop(self) -> None:
|
|
372
|
+
self._httpd.shutdown()
|
|
373
|
+
self._httpd.server_close()
|
{world_engine_client-0.3.0 → world_engine_client-0.4.0}/src/world_engine_client/rotations.py
RENAMED
|
@@ -56,8 +56,7 @@ def matrix_to_axisangle(m: np.ndarray) -> np.ndarray:
|
|
|
56
56
|
if m[0, 2] + m[2, 0] < 0:
|
|
57
57
|
axis[2] = -axis[2]
|
|
58
58
|
return axis * angle
|
|
59
|
-
axis = (
|
|
60
|
-
|
|
61
|
-
/ (2.0 * np.sin(angle))
|
|
59
|
+
axis = np.array([m[2, 1] - m[1, 2], m[0, 2] - m[2, 0], m[1, 0] - m[0, 1]]) / (
|
|
60
|
+
2.0 * np.sin(angle)
|
|
62
61
|
)
|
|
63
62
|
return axis * angle
|
|
@@ -4,15 +4,13 @@ These tests exercise the client alone. The wire contract against a real engine
|
|
|
4
4
|
is covered in the server repository (tests/test_remote_*.py).
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
import io
|
|
8
7
|
import json
|
|
9
8
|
import socket
|
|
10
9
|
import threading
|
|
11
|
-
from http.server import BaseHTTPRequestHandler,
|
|
10
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
12
11
|
|
|
13
12
|
import pytest
|
|
14
13
|
|
|
15
|
-
CONTENT_TYPE = "application/vnd.world-engine.observation-v3"
|
|
16
14
|
DROP = object() # responder return value: close the socket without replying
|
|
17
15
|
|
|
18
16
|
ENV = {
|
|
@@ -33,25 +31,6 @@ ENV = {
|
|
|
33
31
|
OBS = {"robot0_eef_pos": [0.1, 0.2, 0.3], "object_ids": ["cube"]}
|
|
34
32
|
|
|
35
33
|
|
|
36
|
-
def jpeg(width, height, color=(200, 30, 30)):
|
|
37
|
-
from PIL import Image
|
|
38
|
-
|
|
39
|
-
buf = io.BytesIO()
|
|
40
|
-
Image.new("RGB", (width, height), color).save(buf, format="JPEG", quality=90)
|
|
41
|
-
return buf.getvalue()
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def pack(header, chunks, specs=None):
|
|
45
|
-
"""Protocol-3 packet: u32 length, JSON envelope, concatenated JPEGs."""
|
|
46
|
-
if specs is None:
|
|
47
|
-
specs, offset = [], 0
|
|
48
|
-
for name, w, h, data in chunks:
|
|
49
|
-
specs.append({"name": name, "w": w, "h": h, "offset": offset, "length": len(data)})
|
|
50
|
-
offset += len(data)
|
|
51
|
-
meta = json.dumps({"wire_version": 3, **header, "images": specs}).encode()
|
|
52
|
-
return len(meta).to_bytes(4, "little") + meta + b"".join(c[3] for c in chunks)
|
|
53
|
-
|
|
54
|
-
|
|
55
34
|
class ControlStub:
|
|
56
35
|
"""Records every request; ``responders[path]`` overrides the default reply."""
|
|
57
36
|
|
|
@@ -60,6 +39,7 @@ class ControlStub:
|
|
|
60
39
|
self.responders = {}
|
|
61
40
|
self.step_id = 0
|
|
62
41
|
self.episode = 0
|
|
42
|
+
self.files = {} # GET path -> bytes (the /render/ prefix)
|
|
63
43
|
|
|
64
44
|
def default(self, path, body):
|
|
65
45
|
path = path[path.index("/control/") :] # tolerate a gateway prefix
|
|
@@ -126,10 +106,25 @@ def stub():
|
|
|
126
106
|
self.end_headers()
|
|
127
107
|
self.wfile.write(data)
|
|
128
108
|
|
|
109
|
+
def do_GET(self):
|
|
110
|
+
state.calls.append((self.path, None, dict(self.headers)))
|
|
111
|
+
data = state.files.get(self.path)
|
|
112
|
+
if data is None:
|
|
113
|
+
self.send_response(404)
|
|
114
|
+
self.send_header("Content-Length", "0")
|
|
115
|
+
self.end_headers()
|
|
116
|
+
return
|
|
117
|
+
self.send_response(200)
|
|
118
|
+
self.send_header("Content-Type", "application/octet-stream")
|
|
119
|
+
self.send_header("Content-Length", str(len(data)))
|
|
120
|
+
self.end_headers()
|
|
121
|
+
self.wfile.write(data)
|
|
122
|
+
|
|
129
123
|
def log_message(self, *args):
|
|
130
124
|
pass
|
|
131
125
|
|
|
132
|
-
server =
|
|
126
|
+
server = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
127
|
+
server.daemon_threads = True
|
|
133
128
|
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
134
129
|
state.url = f"http://127.0.0.1:{server.server_address[1]}"
|
|
135
130
|
yield state
|
|
@@ -4,13 +4,11 @@ The rule under test: a request whose outcome is unknown is never retried, and
|
|
|
4
4
|
step/reset failures block further actions until an explicit reset or reconnect.
|
|
5
5
|
"""
|
|
6
6
|
|
|
7
|
-
import copy
|
|
8
|
-
import sys
|
|
9
7
|
import threading
|
|
10
8
|
|
|
11
9
|
import numpy as np
|
|
12
10
|
import pytest
|
|
13
|
-
from conftest import
|
|
11
|
+
from conftest import DROP, ENV, OBS
|
|
14
12
|
|
|
15
13
|
import world_engine_client as wec
|
|
16
14
|
from world_engine_client import env as env_module
|
|
@@ -286,48 +284,6 @@ def test_render_requires_an_image_observation(env):
|
|
|
286
284
|
env.render()
|
|
287
285
|
|
|
288
286
|
|
|
289
|
-
def image_env(stub):
|
|
290
|
-
with_cameras = {**copy.deepcopy(ENV), "cameras": CAMERAS, "available_cameras": CAMERAS}
|
|
291
|
-
stub.responders["/control/connect"] = lambda body: {
|
|
292
|
-
"ok": True,
|
|
293
|
-
"session_id": "sess",
|
|
294
|
-
"proto": 3,
|
|
295
|
-
"instance_id": "inst",
|
|
296
|
-
"episode_id": "e0",
|
|
297
|
-
"env": with_cameras,
|
|
298
|
-
}
|
|
299
|
-
return wec.connect("secret", url=stub.url, images=True)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
def test_binary_image_reply_decodes_and_reports_timings(stub):
|
|
303
|
-
env = image_env(stub)
|
|
304
|
-
assert stub.calls[0][1]["images"] is True
|
|
305
|
-
header = {"ok": True, "obs": OBS, "info": {"episode_id": "e0", "timings": {"server_ms": 1}}}
|
|
306
|
-
packet = pack(header, [("front", 8, 6, jpeg(8, 6))])
|
|
307
|
-
stub.responders["/control/observe"] = lambda body: (CONTENT_TYPE, packet)
|
|
308
|
-
obs, info = env.observe()
|
|
309
|
-
assert obs["rgb"]["front"].shape == (6, 8, 3)
|
|
310
|
-
assert env.render() is obs["rgb"]["front"] and env.render("front") is obs["rgb"]["front"]
|
|
311
|
-
assert {"decode_ms", "transport_ms", "total_ms", "server_ms"} <= set(env.last_timings)
|
|
312
|
-
assert env.last_timings["response_bytes"] == len(packet)
|
|
313
|
-
assert env.last_timings["transport_ms"] >= 0
|
|
314
|
-
env.close()
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
def test_image_session_rejects_a_json_downgrade(stub):
|
|
318
|
-
env = image_env(stub)
|
|
319
|
-
with pytest.raises(wec.ProtocolError, match="malformed observation"):
|
|
320
|
-
env.observe()
|
|
321
|
-
env.close()
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
def test_images_without_pillow_fail_before_any_request(stub, monkeypatch):
|
|
325
|
-
monkeypatch.setitem(sys.modules, "PIL", None)
|
|
326
|
-
with pytest.raises(ImportError, match=r"world-engine-client\[img\]"):
|
|
327
|
-
wec.connect("secret", url=stub.url, cameras=["front"])
|
|
328
|
-
assert stub.calls == []
|
|
329
|
-
|
|
330
|
-
|
|
331
287
|
class Ticks:
|
|
332
288
|
"""Stands in for the stop event: ``wait`` returns immediately, n times."""
|
|
333
289
|
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Client rendering without a browser: the extra requirement, the render_state
|
|
2
|
+
hand-off, credentials on asset requests and the digest cache."""
|
|
3
|
+
|
|
4
|
+
import copy
|
|
5
|
+
import hashlib
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import pytest
|
|
9
|
+
from conftest import ENV, OBS
|
|
10
|
+
|
|
11
|
+
import world_engine_client as wec
|
|
12
|
+
from world_engine_client import render as render_module
|
|
13
|
+
|
|
14
|
+
CAMERAS = {"front": {"width": 8, "height": 6}}
|
|
15
|
+
STATE = {"type": "state", "frame_id": 0, "bodies": [], "robot": {}, "cameras": []}
|
|
16
|
+
BUNDLE = {
|
|
17
|
+
"protocol": 1,
|
|
18
|
+
"scene_init": {"type": "scene_init", "cameras": []},
|
|
19
|
+
"digests": {"/__asset/0.ply": "ab" * 32},
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class FakeRenderer:
|
|
24
|
+
instances = []
|
|
25
|
+
|
|
26
|
+
def __init__(self, fetch, scene_init, digests, **options):
|
|
27
|
+
self.fetch, self.scene_init, self.digests = fetch, scene_init, digests
|
|
28
|
+
self.options = options
|
|
29
|
+
self.states, self.closed, self.fail = [], False, False
|
|
30
|
+
FakeRenderer.instances.append(self)
|
|
31
|
+
|
|
32
|
+
def render(self, state):
|
|
33
|
+
if self.fail:
|
|
34
|
+
raise render_module.RendererError("GPU lost", code="renderer_unavailable")
|
|
35
|
+
self.states.append(state)
|
|
36
|
+
return {"front": np.full((6, 8, 3), len(self.states), np.uint8)}
|
|
37
|
+
|
|
38
|
+
def close(self):
|
|
39
|
+
self.closed = True
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@pytest.fixture
|
|
43
|
+
def client_render(stub, monkeypatch):
|
|
44
|
+
"""A stub that offers client rendering; LocalRenderer replaced by a fake."""
|
|
45
|
+
FakeRenderer.instances = []
|
|
46
|
+
monkeypatch.setattr(render_module, "available", lambda: True)
|
|
47
|
+
monkeypatch.setattr(render_module, "LocalRenderer", FakeRenderer)
|
|
48
|
+
env = {**copy.deepcopy(ENV), "cameras": CAMERAS, "render": "client"}
|
|
49
|
+
stub.responders["/control/connect"] = lambda body: {
|
|
50
|
+
"ok": True,
|
|
51
|
+
"session_id": "sess",
|
|
52
|
+
"proto": 3,
|
|
53
|
+
"instance_id": "inst",
|
|
54
|
+
"episode_id": "e0",
|
|
55
|
+
"env": env,
|
|
56
|
+
**({"render": BUNDLE} if body.get("render") == "client" else {}),
|
|
57
|
+
}
|
|
58
|
+
for path in ("/control/reset", "/control/observe"):
|
|
59
|
+
stub.responders[path] = lambda body: {
|
|
60
|
+
"ok": True,
|
|
61
|
+
"obs": dict(OBS),
|
|
62
|
+
"info": {"episode_id": "e1", "step_id": 0},
|
|
63
|
+
"render_state": STATE,
|
|
64
|
+
}
|
|
65
|
+
stub.responders["/control/step"] = lambda body: {
|
|
66
|
+
"ok": True,
|
|
67
|
+
"obs": dict(OBS),
|
|
68
|
+
"info": {"episode_id": "e1", "step_id": 1},
|
|
69
|
+
"reward": 0.0,
|
|
70
|
+
"terminated": False,
|
|
71
|
+
"truncated": False,
|
|
72
|
+
"render_state": STATE,
|
|
73
|
+
}
|
|
74
|
+
return stub
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_images_are_rendered_locally(client_render):
|
|
78
|
+
env = wec.connect("secret", url=client_render.url, images=True, render_options={"x": 1})
|
|
79
|
+
connect = client_render.calls[0][1]
|
|
80
|
+
assert connect["render"] == "client" and connect["images"] is True
|
|
81
|
+
fake = FakeRenderer.instances[0]
|
|
82
|
+
assert fake.scene_init == BUNDLE["scene_init"] and fake.options == {"x": 1}
|
|
83
|
+
obs, _ = env.reset(seed=0)
|
|
84
|
+
assert obs["rgb"]["front"].shape == (6, 8, 3) and fake.states == [STATE]
|
|
85
|
+
assert env.render() is obs["rgb"]["front"]
|
|
86
|
+
env.close()
|
|
87
|
+
assert fake.closed and client_render.paths()[-1] == "/control/release"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@pytest.mark.parametrize("kw", [{"images": True}, {"cameras": ["front"]}])
|
|
91
|
+
def test_images_without_the_extra_fail_before_any_request(stub, monkeypatch, kw):
|
|
92
|
+
monkeypatch.setattr(render_module, "available", lambda: False)
|
|
93
|
+
with pytest.raises(wec.RendererError, match=r"render\]"):
|
|
94
|
+
wec.connect("secret", url=stub.url, **kw)
|
|
95
|
+
assert stub.calls == []
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
@pytest.mark.parametrize("mode", ["server", "browser"])
|
|
99
|
+
def test_servers_never_render(stub, mode):
|
|
100
|
+
with pytest.raises(ValueError, match="no longer render"):
|
|
101
|
+
wec.connect("secret", url=stub.url, images=True, render=mode)
|
|
102
|
+
assert stub.calls == []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_a_server_without_client_rendering_is_released(client_render):
|
|
106
|
+
client_render.responders["/control/connect"] = lambda body: {
|
|
107
|
+
"ok": True,
|
|
108
|
+
"session_id": "sess",
|
|
109
|
+
"proto": 3,
|
|
110
|
+
"instance_id": "inst",
|
|
111
|
+
"episode_id": "e0",
|
|
112
|
+
"env": {**copy.deepcopy(ENV), "cameras": CAMERAS},
|
|
113
|
+
}
|
|
114
|
+
with pytest.raises(wec.ProtocolError, match="predates client rendering"):
|
|
115
|
+
wec.connect("secret", url=client_render.url, images=True)
|
|
116
|
+
assert client_render.paths()[-1] == "/control/release"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_a_render_failure_leaves_physics_and_observe_renders_again(client_render):
|
|
120
|
+
env = wec.connect("secret", url=client_render.url, images=True)
|
|
121
|
+
env.reset(seed=0)
|
|
122
|
+
fake = FakeRenderer.instances[0]
|
|
123
|
+
fake.fail = True
|
|
124
|
+
with pytest.raises(wec.RendererError, match="GPU lost"):
|
|
125
|
+
env.observe()
|
|
126
|
+
fake.fail = False
|
|
127
|
+
obs, _ = env.observe()
|
|
128
|
+
assert obs["rgb"]["front"][0, 0, 0] == 2
|
|
129
|
+
obs, *_ = env.step([0, 0, 0, 0]) # the failed render blocked no action
|
|
130
|
+
assert obs["rgb"]["front"][0, 0, 0] == 3
|
|
131
|
+
env.close()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_an_observation_without_poses_is_a_protocol_error(client_render):
|
|
135
|
+
env = wec.connect("secret", url=client_render.url, images=True)
|
|
136
|
+
client_render.responders["/control/observe"] = lambda body: {
|
|
137
|
+
"ok": True,
|
|
138
|
+
"obs": dict(OBS),
|
|
139
|
+
"info": {"episode_id": "e1", "step_id": 0},
|
|
140
|
+
}
|
|
141
|
+
with pytest.raises(wec.ProtocolError, match="malformed observation"):
|
|
142
|
+
env.observe()
|
|
143
|
+
env.close()
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_asset_requests_carry_the_session_credentials(client_render):
|
|
147
|
+
env = wec.connect(
|
|
148
|
+
"secret", url=client_render.url, images=True, access_provider=lambda: "scoped"
|
|
149
|
+
)
|
|
150
|
+
client_render.files["/render/index.html"] = b"<html>"
|
|
151
|
+
with env._open_render("/index.html") as response:
|
|
152
|
+
assert response.read() == b"<html>"
|
|
153
|
+
path, _, headers = client_render.calls[-1]
|
|
154
|
+
assert path == "/render/index.html"
|
|
155
|
+
assert headers["X-We-Control-Token"] == "secret"
|
|
156
|
+
assert headers["Authorization"] == "Bearer scoped"
|
|
157
|
+
with pytest.raises(FileNotFoundError):
|
|
158
|
+
env._open_render("/missing.js")
|
|
159
|
+
env.close()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def test_downloads_are_verified_against_their_digest(tmp_path, stub):
|
|
163
|
+
renderer = object.__new__(render_module.LocalRenderer) # no browser needed
|
|
164
|
+
env = wec.connect("secret", url=stub.url)
|
|
165
|
+
renderer._fetch = env._open_render
|
|
166
|
+
data = b"splat bytes"
|
|
167
|
+
stub.files["/render/__asset/0.ply"] = data
|
|
168
|
+
good = tmp_path / hashlib.sha256(data).hexdigest()
|
|
169
|
+
renderer._download("/__asset/0.ply", good.name, good)
|
|
170
|
+
assert good.read_bytes() == data
|
|
171
|
+
bad = tmp_path / ("0" * 64)
|
|
172
|
+
with pytest.raises(wec.RendererError, match="digest"):
|
|
173
|
+
renderer._download("/__asset/0.ply", bad.name, bad)
|
|
174
|
+
assert not bad.exists() and not bad.with_suffix(".part").exists()
|
|
175
|
+
env.close()
|
|
@@ -1,66 +0,0 @@
|
|
|
1
|
-
"""Strict protocol-3 binary decoder; Pillow is imported only for images."""
|
|
2
|
-
|
|
3
|
-
import io
|
|
4
|
-
import json
|
|
5
|
-
|
|
6
|
-
import numpy as np
|
|
7
|
-
|
|
8
|
-
from .errors import ProtocolError
|
|
9
|
-
|
|
10
|
-
CONTENT_TYPE = "application/vnd.world-engine.observation-v3"
|
|
11
|
-
MAX_RESPONSE = 64 * 1024 * 1024
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def decode(data: bytes, cameras: dict) -> dict:
|
|
15
|
-
try:
|
|
16
|
-
if len(data) < 4 or len(data) > MAX_RESPONSE:
|
|
17
|
-
raise ValueError("invalid packet length")
|
|
18
|
-
length = int.from_bytes(data[:4], "little")
|
|
19
|
-
if not 0 < length <= min(len(data) - 4, 1024 * 1024):
|
|
20
|
-
raise ValueError("invalid metadata length")
|
|
21
|
-
header = json.loads(data[4 : 4 + length])
|
|
22
|
-
if type(header["wire_version"]) is not int or header["wire_version"] != 3:
|
|
23
|
-
raise ValueError("unsupported observation version")
|
|
24
|
-
payload = data[4 + length :]
|
|
25
|
-
seen, offset = set(), 0
|
|
26
|
-
for spec in header["images"]:
|
|
27
|
-
name = spec["name"]
|
|
28
|
-
if name in seen or name not in cameras:
|
|
29
|
-
raise ValueError("unexpected or duplicate camera")
|
|
30
|
-
seen.add(name)
|
|
31
|
-
n = spec["length"]
|
|
32
|
-
if (
|
|
33
|
-
type(n) is not int
|
|
34
|
-
or type(spec["offset"]) is not int
|
|
35
|
-
or type(spec["w"]) is not int
|
|
36
|
-
or type(spec["h"]) is not int
|
|
37
|
-
or n <= 0
|
|
38
|
-
or spec["offset"] != offset
|
|
39
|
-
or offset + n > len(payload)
|
|
40
|
-
):
|
|
41
|
-
raise ValueError("invalid image offset/length")
|
|
42
|
-
expected = cameras[name]
|
|
43
|
-
if (spec["w"], spec["h"]) != (expected["width"], expected["height"]):
|
|
44
|
-
raise ValueError("camera dimensions changed")
|
|
45
|
-
offset += n
|
|
46
|
-
if seen != set(cameras) or offset != len(payload):
|
|
47
|
-
raise ValueError("missing images or trailing data")
|
|
48
|
-
try:
|
|
49
|
-
from PIL import Image
|
|
50
|
-
except ImportError as exc:
|
|
51
|
-
raise ImportError(
|
|
52
|
-
"RGB observations require pip install 'world-engine-client[img]'"
|
|
53
|
-
) from exc
|
|
54
|
-
rgb = {}
|
|
55
|
-
for spec in header["images"]:
|
|
56
|
-
chunk = payload[spec["offset"] : spec["offset"] + spec["length"]]
|
|
57
|
-
if not chunk.endswith(b"\xff\xd9"):
|
|
58
|
-
raise ValueError("truncated JPEG")
|
|
59
|
-
with Image.open(io.BytesIO(chunk)) as img:
|
|
60
|
-
if img.format != "JPEG" or img.size != (spec["w"], spec["h"]):
|
|
61
|
-
raise ValueError("decoded image dimensions or format mismatch")
|
|
62
|
-
rgb[spec["name"]] = np.asarray(img.convert("RGB"), dtype=np.uint8).copy()
|
|
63
|
-
header["obs"]["rgb"] = rgb
|
|
64
|
-
return header
|
|
65
|
-
except (KeyError, TypeError, ValueError, OSError) as exc:
|
|
66
|
-
raise ProtocolError(f"malformed observation: {exc}") from exc
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
"""wire.decode accepts exactly the documented packet and nothing else."""
|
|
2
|
-
|
|
3
|
-
import json
|
|
4
|
-
import sys
|
|
5
|
-
|
|
6
|
-
import numpy as np
|
|
7
|
-
import pytest
|
|
8
|
-
from conftest import jpeg, pack
|
|
9
|
-
|
|
10
|
-
from world_engine_client import ProtocolError, wire
|
|
11
|
-
|
|
12
|
-
CAMERAS = {"front": {"width": 8, "height": 6}, "wrist": {"width": 4, "height": 4}}
|
|
13
|
-
HEADER = {"obs": {"robot0_eef_pos": [0, 0, 0]}, "info": {"step_id": 1}}
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
def chunks():
|
|
17
|
-
return [("front", 8, 6, jpeg(8, 6)), ("wrist", 4, 4, jpeg(4, 4, (10, 10, 240)))]
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
def test_round_trip_returns_rgb_uint8_per_camera():
|
|
21
|
-
reply = wire.decode(pack(HEADER, chunks()), CAMERAS)
|
|
22
|
-
rgb = reply["obs"]["rgb"]
|
|
23
|
-
assert list(rgb) == ["front", "wrist"]
|
|
24
|
-
assert rgb["front"].shape == (6, 8, 3) and rgb["front"].dtype == np.uint8
|
|
25
|
-
assert rgb["wrist"].shape == (4, 4, 3)
|
|
26
|
-
# Channel order survives: the first camera is red, the second blue.
|
|
27
|
-
assert rgb["front"][0, 0, 0] > 150 > rgb["front"][0, 0, 2]
|
|
28
|
-
assert rgb["wrist"][0, 0, 2] > 150 > rgb["wrist"][0, 0, 0]
|
|
29
|
-
assert reply["info"] == {"step_id": 1}
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def raw(header: bytes, payload: bytes = b"") -> bytes:
|
|
33
|
-
return len(header).to_bytes(4, "little") + header + payload
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def respec(**change):
|
|
37
|
-
"""A two-camera packet whose first descriptor is altered."""
|
|
38
|
-
parts = chunks()
|
|
39
|
-
specs, offset = [], 0
|
|
40
|
-
for name, w, h, data in parts:
|
|
41
|
-
specs.append({"name": name, "w": w, "h": h, "offset": offset, "length": len(data)})
|
|
42
|
-
offset += len(data)
|
|
43
|
-
specs[0].update(change)
|
|
44
|
-
return pack(HEADER, parts, specs)
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
@pytest.mark.parametrize(
|
|
48
|
-
"packet",
|
|
49
|
-
[
|
|
50
|
-
b"",
|
|
51
|
-
b"\x01\x00",
|
|
52
|
-
raw(b""), # zero metadata length
|
|
53
|
-
(2 * 1024 * 1024).to_bytes(4, "little") + b"{}", # metadata longer than the packet
|
|
54
|
-
raw(b"{not json"),
|
|
55
|
-
raw(json.dumps({"wire_version": 3, "obs": {}}).encode()), # no images key
|
|
56
|
-
raw(json.dumps({"wire_version": 3, "images": []}).encode()), # no obs, missing cameras
|
|
57
|
-
raw(json.dumps([1, 2]).encode()),
|
|
58
|
-
],
|
|
59
|
-
ids=["empty", "short", "zero-meta", "meta-overrun", "bad-json", "no-images", "no-obs", "list"],
|
|
60
|
-
)
|
|
61
|
-
def test_malformed_envelopes_are_rejected(packet):
|
|
62
|
-
with pytest.raises(ProtocolError, match="malformed observation"):
|
|
63
|
-
wire.decode(packet, CAMERAS)
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
@pytest.mark.parametrize("version", [2, 4, "3", 3.0, True, None])
|
|
67
|
-
def test_only_integer_wire_version_3_is_accepted(version):
|
|
68
|
-
meta = json.dumps({"wire_version": version, "obs": {}, "images": []}).encode()
|
|
69
|
-
with pytest.raises(ProtocolError):
|
|
70
|
-
wire.decode(raw(meta), {})
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
@pytest.mark.parametrize(
|
|
74
|
-
"change",
|
|
75
|
-
[
|
|
76
|
-
{"name": "wrist"}, # duplicate
|
|
77
|
-
{"name": "side"}, # not negotiated
|
|
78
|
-
{"length": 0},
|
|
79
|
-
{"length": -5},
|
|
80
|
-
{"length": "12"},
|
|
81
|
-
{"length": 12.0},
|
|
82
|
-
{"offset": 1},
|
|
83
|
-
{"offset": "0"},
|
|
84
|
-
{"w": 8.0},
|
|
85
|
-
{"h": "6"},
|
|
86
|
-
{"w": 16}, # disagrees with the negotiated dimensions
|
|
87
|
-
{"length": 10**9}, # beyond the payload
|
|
88
|
-
],
|
|
89
|
-
ids=lambda c: "-".join(f"{k}={v!r}" for k, v in c.items()),
|
|
90
|
-
)
|
|
91
|
-
def test_bad_image_descriptors_are_rejected(change):
|
|
92
|
-
with pytest.raises(ProtocolError):
|
|
93
|
-
wire.decode(respec(**change), CAMERAS)
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
def test_missing_camera_and_trailing_bytes_are_rejected():
|
|
97
|
-
with pytest.raises(ProtocolError, match="missing images"):
|
|
98
|
-
wire.decode(pack(HEADER, chunks()[:1]), CAMERAS)
|
|
99
|
-
with pytest.raises(ProtocolError, match="trailing"):
|
|
100
|
-
wire.decode(pack(HEADER, chunks()) + b"\x00", CAMERAS)
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
def test_non_contiguous_offsets_are_rejected():
|
|
104
|
-
parts = chunks()
|
|
105
|
-
specs = [
|
|
106
|
-
{"name": "front", "w": 8, "h": 6, "offset": 0, "length": len(parts[0][3])},
|
|
107
|
-
{"name": "wrist", "w": 4, "h": 4, "offset": len(parts[0][3]) + 1, "length": 1},
|
|
108
|
-
]
|
|
109
|
-
with pytest.raises(ProtocolError, match="offset"):
|
|
110
|
-
wire.decode(pack(HEADER, parts, specs), CAMERAS)
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def test_truncated_and_non_jpeg_images_are_rejected():
|
|
114
|
-
one = {"front": CAMERAS["front"]}
|
|
115
|
-
good = jpeg(8, 6)
|
|
116
|
-
with pytest.raises(ProtocolError, match="truncated JPEG"):
|
|
117
|
-
wire.decode(pack(HEADER, [("front", 8, 6, good[:-2])]), one)
|
|
118
|
-
with pytest.raises(ProtocolError):
|
|
119
|
-
wire.decode(pack(HEADER, [("front", 8, 6, b"not an image" + b"\xff\xd9")]), one)
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def test_decoded_size_must_match_descriptor():
|
|
123
|
-
# The descriptor and the negotiated camera agree, but the JPEG itself is 4x4.
|
|
124
|
-
packet = pack(HEADER, [("front", 8, 6, jpeg(4, 4))])
|
|
125
|
-
with pytest.raises(ProtocolError, match="dimensions or format"):
|
|
126
|
-
wire.decode(packet, {"front": CAMERAS["front"]})
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def test_response_size_limit(monkeypatch):
|
|
130
|
-
packet = pack(HEADER, chunks())
|
|
131
|
-
monkeypatch.setattr(wire, "MAX_RESPONSE", len(packet) - 1)
|
|
132
|
-
with pytest.raises(ProtocolError, match="packet length"):
|
|
133
|
-
wire.decode(packet, CAMERAS)
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
def test_missing_pillow_is_an_actionable_import_error(monkeypatch):
|
|
137
|
-
packet = pack(HEADER, chunks())
|
|
138
|
-
monkeypatch.setitem(sys.modules, "PIL", None)
|
|
139
|
-
with pytest.raises(ImportError, match=r"world-engine-client\[img\]"):
|
|
140
|
-
wire.decode(packet, CAMERAS)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{world_engine_client-0.3.0 → world_engine_client-0.4.0}/src/world_engine_client/gym_adapter.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|