uranus-sdk 0.1.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.
- uranus_sdk-0.1.0/PKG-INFO +154 -0
- uranus_sdk-0.1.0/README.md +142 -0
- uranus_sdk-0.1.0/pyproject.toml +23 -0
- uranus_sdk-0.1.0/src/uranus_sdk/__init__.py +21 -0
- uranus_sdk-0.1.0/src/uranus_sdk/dataset.py +182 -0
- uranus_sdk-0.1.0/src/uranus_sdk/session.py +350 -0
- uranus_sdk-0.1.0/src/uranus_sdk/specs.py +98 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: uranus-sdk
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Python SDK for the Uranus video generation serving API
|
|
5
|
+
Author: D-Robotics Large Model Team
|
|
6
|
+
Author-email: D-Robotics Large Model Team <vincent.qin@d-robotics.cc>
|
|
7
|
+
Requires-Dist: requests>=2.31
|
|
8
|
+
Requires-Dist: numpy>=1.24
|
|
9
|
+
Requires-Dist: opencv-python>=4.8
|
|
10
|
+
Requires-Python: >=3.11
|
|
11
|
+
Description-Content-Type: text/markdown
|
|
12
|
+
|
|
13
|
+
# Uranus SDK
|
|
14
|
+
|
|
15
|
+
Python SDK for the Uranus video-generation serving API. Provides a
|
|
16
|
+
high-level `UranusSimulationSession` class that wraps HTTP communication,
|
|
17
|
+
multipart parsing, session management, and authentication.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
pip install uranus-sdk
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Quick Start (simplest)
|
|
26
|
+
|
|
27
|
+
Load a sample from HuggingFace, run inference, save to mp4:
|
|
28
|
+
|
|
29
|
+
```python
|
|
30
|
+
import os
|
|
31
|
+
import cv2
|
|
32
|
+
import numpy as np
|
|
33
|
+
from uranus_sdk import load_sample, UranusSimulationSession
|
|
34
|
+
|
|
35
|
+
os.environ["URANUS_BASE_URL"] = "http://localhost:8000"
|
|
36
|
+
os.environ["URANUS_API_KEY"] = "sk-your-token" # optional
|
|
37
|
+
|
|
38
|
+
# 1. Load sample from HuggingFace Hub (set HF_ENDPOINT to use a mirror)
|
|
39
|
+
sample = load_sample("D-Robotics/Uranus-Demo-Data/resolve/main/rc-table30-v1__rc-aloha/000000",
|
|
40
|
+
num_chunks=8)
|
|
41
|
+
|
|
42
|
+
# 2. Create session + run step loop
|
|
43
|
+
with UranusSimulationSession(**sample["create"]) as session:
|
|
44
|
+
all_frames = {}
|
|
45
|
+
for step in sample["steps"]:
|
|
46
|
+
frames = session.step(**step)
|
|
47
|
+
for cam, imgs in frames.items():
|
|
48
|
+
all_frames.setdefault(cam, []).extend(imgs)
|
|
49
|
+
|
|
50
|
+
# 3. Save to mp4
|
|
51
|
+
for cam, imgs in all_frames.items():
|
|
52
|
+
h, w = imgs[0].shape[:2]
|
|
53
|
+
writer = cv2.VideoWriter(f"{cam}.mp4", cv2.VideoWriter_fourcc(*"mp4v"), 10, (w, h))
|
|
54
|
+
for img in imgs:
|
|
55
|
+
writer.write(cv2.cvtColor(img, cv2.COLOR_RGB2BGR))
|
|
56
|
+
writer.release()
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Configuration
|
|
60
|
+
|
|
61
|
+
| Variable | Required | Default | Description |
|
|
62
|
+
|---|---|---|---|
|
|
63
|
+
| `URANUS_BASE_URL` | No | `http://localhost:8000` | Server address |
|
|
64
|
+
| `URANUS_API_KEY` | No | -- | Bearer token |
|
|
65
|
+
| `HF_ENDPOINT` | No | `https://huggingface.co` | HuggingFace endpoint (use `https://hf-mirror.com` for mirror) |
|
|
66
|
+
|
|
67
|
+
## Configuration
|
|
68
|
+
|
|
69
|
+
Server URL and authentication are read from environment variables:
|
|
70
|
+
|
|
71
|
+
| Variable | Required | Default | Description |
|
|
72
|
+
|---|---|---|---|
|
|
73
|
+
| `URANUS_BASE_URL` | No | `http://localhost:8000` | Server address |
|
|
74
|
+
| `URANUS_API_KEY` | No | — | Bearer token; when set, every request carries `Authorization: Bearer <token>` |
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
export URANUS_BASE_URL=http://uranus-server:8000
|
|
78
|
+
export URANUS_API_KEY=sk-xxxx
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## API
|
|
82
|
+
|
|
83
|
+
### `UranusSimulationSession(...)`
|
|
84
|
+
|
|
85
|
+
Construction triggers `POST /create` — the session is ready immediately.
|
|
86
|
+
|
|
87
|
+
**Parameters:**
|
|
88
|
+
|
|
89
|
+
| Parameter | Type | Required | Description |
|
|
90
|
+
|---|---|---|---|
|
|
91
|
+
| `prompt` | `str` | Yes | Natural-language task description |
|
|
92
|
+
| `mjcf_path` | `str \| Path` | Yes | MJCF XML file path (read and sent inline) |
|
|
93
|
+
| `cameras` | `list[CameraSpec]` | Yes | Ordered camera specs; order fixes output frame order |
|
|
94
|
+
| `ref_cam_images` | `list[str \| bytes \| ndarray]` | Yes | One reference image per camera (path, bytes, or RGB array) |
|
|
95
|
+
| `ref_qpos` | `list[float] \| ndarray` | Yes | Complete MuJoCo qpos vector for the reference frame |
|
|
96
|
+
| `robot2world_transform` | `ndarray \| None` | No | 4x4 robot-to-world matrix; omit for fixed-base |
|
|
97
|
+
| `end_effectors` | `list[EESpec] \| None` | No | End-effector specs (aligned with OSS) |
|
|
98
|
+
| `skeleton` | `SkeletonSpec \| None` | No | Skeleton topology spec (aligned with OSS) |
|
|
99
|
+
| `target_size` | `tuple[int, int]` | No | Generation resolution (height, width); default (384, 640) |
|
|
100
|
+
| `seed` | `int` | No | RNG seed; default 1 |
|
|
101
|
+
| `session_id` | `str \| None` | No | Custom session ID; auto-generated if omitted |
|
|
102
|
+
| `timeout` | `float` | No | HTTP timeout in seconds; default 600 |
|
|
103
|
+
|
|
104
|
+
### `session.step(qpos, *, robot2world_transforms=None)`
|
|
105
|
+
|
|
106
|
+
Generate video frames for a chunk of motion.
|
|
107
|
+
|
|
108
|
+
**Parameters:**
|
|
109
|
+
|
|
110
|
+
| Parameter | Type | Required | Description |
|
|
111
|
+
|---|---|---|---|
|
|
112
|
+
| `qpos` | `ndarray` or `list` | Yes | Motion frames `(N, nq)`. Server rounds N up to a multiple of 4 |
|
|
113
|
+
| `robot2world_transforms` | `list[ndarray \| None] \| None` | No | Per-frame 4x4 transforms; None = identity |
|
|
114
|
+
|
|
115
|
+
**Returns:** `dict[str, list[np.ndarray]]` -- `{camera_name: [RGB uint8 (H,W,3), ...]}`
|
|
116
|
+
|
|
117
|
+
### `session.finish()`
|
|
118
|
+
|
|
119
|
+
Release the session. Idempotent. Called automatically by `__exit__`.
|
|
120
|
+
|
|
121
|
+
## Spec Classes
|
|
122
|
+
|
|
123
|
+
The SDK reuses Spec classes from `uranus.skeleton` when the full Uranus
|
|
124
|
+
package is installed. Lightweight fallbacks are provided when Uranus is
|
|
125
|
+
not available -- they serialize to the same JSON.
|
|
126
|
+
|
|
127
|
+
### `CameraSpec(name: str)`
|
|
128
|
+
|
|
129
|
+
### `EESpec(object_type, object_name, radius_mode, pad_bodies?, radius?, sh_correction?)`
|
|
130
|
+
|
|
131
|
+
### `SkeletonSpec(mode, chains, skip_bodies, gripper_keypoint_overrides?)`
|
|
132
|
+
|
|
133
|
+
## Error Handling
|
|
134
|
+
|
|
135
|
+
```python
|
|
136
|
+
from uranus_sdk import UranusSDKError
|
|
137
|
+
|
|
138
|
+
try:
|
|
139
|
+
session = UranusSimulationSession(...)
|
|
140
|
+
except UranusSDKError as e:
|
|
141
|
+
print(f"Failed: {e} (code={e.code}, status={e.status_code})")
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
| Scenario | HTTP Status | Code |
|
|
145
|
+
|---|---|---|
|
|
146
|
+
| Invalid parameters | 400 | `invalid_payload` |
|
|
147
|
+
| Session not found | 404 | `unknown_session` |
|
|
148
|
+
| Session already exists | 409 | `session_exists` |
|
|
149
|
+
| Server busy | 503 | `server_busy` |
|
|
150
|
+
| Auth failure | 401 | -- |
|
|
151
|
+
|
|
152
|
+
## License
|
|
153
|
+
|
|
154
|
+
MIT
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# Uranus SDK
|
|
2
|
+
|
|
3
|
+
Python SDK for the Uranus video-generation serving API. Provides a
|
|
4
|
+
high-level `UranusSimulationSession` class that wraps HTTP communication,
|
|
5
|
+
multipart parsing, session management, and authentication.
|
|
6
|
+
|
|
7
|
+
## Installation
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install uranus-sdk
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start (simplest)
|
|
14
|
+
|
|
15
|
+
Load a sample from HuggingFace, run inference, save to mp4:
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
import os
|
|
19
|
+
import cv2
|
|
20
|
+
import numpy as np
|
|
21
|
+
from uranus_sdk import load_sample, UranusSimulationSession
|
|
22
|
+
|
|
23
|
+
os.environ["URANUS_BASE_URL"] = "http://localhost:8000"
|
|
24
|
+
os.environ["URANUS_API_KEY"] = "sk-your-token" # optional
|
|
25
|
+
|
|
26
|
+
# 1. Load sample from HuggingFace Hub (set HF_ENDPOINT to use a mirror)
|
|
27
|
+
sample = load_sample("D-Robotics/Uranus-Demo-Data/resolve/main/rc-table30-v1__rc-aloha/000000",
|
|
28
|
+
num_chunks=8)
|
|
29
|
+
|
|
30
|
+
# 2. Create session + run step loop
|
|
31
|
+
with UranusSimulationSession(**sample["create"]) as session:
|
|
32
|
+
all_frames = {}
|
|
33
|
+
for step in sample["steps"]:
|
|
34
|
+
frames = session.step(**step)
|
|
35
|
+
for cam, imgs in frames.items():
|
|
36
|
+
all_frames.setdefault(cam, []).extend(imgs)
|
|
37
|
+
|
|
38
|
+
# 3. Save to mp4
|
|
39
|
+
for cam, imgs in all_frames.items():
|
|
40
|
+
h, w = imgs[0].shape[:2]
|
|
41
|
+
writer = cv2.VideoWriter(f"{cam}.mp4", cv2.VideoWriter_fourcc(*"mp4v"), 10, (w, h))
|
|
42
|
+
for img in imgs:
|
|
43
|
+
writer.write(cv2.cvtColor(img, cv2.COLOR_RGB2BGR))
|
|
44
|
+
writer.release()
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## Configuration
|
|
48
|
+
|
|
49
|
+
| Variable | Required | Default | Description |
|
|
50
|
+
|---|---|---|---|
|
|
51
|
+
| `URANUS_BASE_URL` | No | `http://localhost:8000` | Server address |
|
|
52
|
+
| `URANUS_API_KEY` | No | -- | Bearer token |
|
|
53
|
+
| `HF_ENDPOINT` | No | `https://huggingface.co` | HuggingFace endpoint (use `https://hf-mirror.com` for mirror) |
|
|
54
|
+
|
|
55
|
+
## Configuration
|
|
56
|
+
|
|
57
|
+
Server URL and authentication are read from environment variables:
|
|
58
|
+
|
|
59
|
+
| Variable | Required | Default | Description |
|
|
60
|
+
|---|---|---|---|
|
|
61
|
+
| `URANUS_BASE_URL` | No | `http://localhost:8000` | Server address |
|
|
62
|
+
| `URANUS_API_KEY` | No | — | Bearer token; when set, every request carries `Authorization: Bearer <token>` |
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
export URANUS_BASE_URL=http://uranus-server:8000
|
|
66
|
+
export URANUS_API_KEY=sk-xxxx
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
## API
|
|
70
|
+
|
|
71
|
+
### `UranusSimulationSession(...)`
|
|
72
|
+
|
|
73
|
+
Construction triggers `POST /create` — the session is ready immediately.
|
|
74
|
+
|
|
75
|
+
**Parameters:**
|
|
76
|
+
|
|
77
|
+
| Parameter | Type | Required | Description |
|
|
78
|
+
|---|---|---|---|
|
|
79
|
+
| `prompt` | `str` | Yes | Natural-language task description |
|
|
80
|
+
| `mjcf_path` | `str \| Path` | Yes | MJCF XML file path (read and sent inline) |
|
|
81
|
+
| `cameras` | `list[CameraSpec]` | Yes | Ordered camera specs; order fixes output frame order |
|
|
82
|
+
| `ref_cam_images` | `list[str \| bytes \| ndarray]` | Yes | One reference image per camera (path, bytes, or RGB array) |
|
|
83
|
+
| `ref_qpos` | `list[float] \| ndarray` | Yes | Complete MuJoCo qpos vector for the reference frame |
|
|
84
|
+
| `robot2world_transform` | `ndarray \| None` | No | 4x4 robot-to-world matrix; omit for fixed-base |
|
|
85
|
+
| `end_effectors` | `list[EESpec] \| None` | No | End-effector specs (aligned with OSS) |
|
|
86
|
+
| `skeleton` | `SkeletonSpec \| None` | No | Skeleton topology spec (aligned with OSS) |
|
|
87
|
+
| `target_size` | `tuple[int, int]` | No | Generation resolution (height, width); default (384, 640) |
|
|
88
|
+
| `seed` | `int` | No | RNG seed; default 1 |
|
|
89
|
+
| `session_id` | `str \| None` | No | Custom session ID; auto-generated if omitted |
|
|
90
|
+
| `timeout` | `float` | No | HTTP timeout in seconds; default 600 |
|
|
91
|
+
|
|
92
|
+
### `session.step(qpos, *, robot2world_transforms=None)`
|
|
93
|
+
|
|
94
|
+
Generate video frames for a chunk of motion.
|
|
95
|
+
|
|
96
|
+
**Parameters:**
|
|
97
|
+
|
|
98
|
+
| Parameter | Type | Required | Description |
|
|
99
|
+
|---|---|---|---|
|
|
100
|
+
| `qpos` | `ndarray` or `list` | Yes | Motion frames `(N, nq)`. Server rounds N up to a multiple of 4 |
|
|
101
|
+
| `robot2world_transforms` | `list[ndarray \| None] \| None` | No | Per-frame 4x4 transforms; None = identity |
|
|
102
|
+
|
|
103
|
+
**Returns:** `dict[str, list[np.ndarray]]` -- `{camera_name: [RGB uint8 (H,W,3), ...]}`
|
|
104
|
+
|
|
105
|
+
### `session.finish()`
|
|
106
|
+
|
|
107
|
+
Release the session. Idempotent. Called automatically by `__exit__`.
|
|
108
|
+
|
|
109
|
+
## Spec Classes
|
|
110
|
+
|
|
111
|
+
The SDK reuses Spec classes from `uranus.skeleton` when the full Uranus
|
|
112
|
+
package is installed. Lightweight fallbacks are provided when Uranus is
|
|
113
|
+
not available -- they serialize to the same JSON.
|
|
114
|
+
|
|
115
|
+
### `CameraSpec(name: str)`
|
|
116
|
+
|
|
117
|
+
### `EESpec(object_type, object_name, radius_mode, pad_bodies?, radius?, sh_correction?)`
|
|
118
|
+
|
|
119
|
+
### `SkeletonSpec(mode, chains, skip_bodies, gripper_keypoint_overrides?)`
|
|
120
|
+
|
|
121
|
+
## Error Handling
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from uranus_sdk import UranusSDKError
|
|
125
|
+
|
|
126
|
+
try:
|
|
127
|
+
session = UranusSimulationSession(...)
|
|
128
|
+
except UranusSDKError as e:
|
|
129
|
+
print(f"Failed: {e} (code={e.code}, status={e.status_code})")
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
| Scenario | HTTP Status | Code |
|
|
133
|
+
|---|---|---|
|
|
134
|
+
| Invalid parameters | 400 | `invalid_payload` |
|
|
135
|
+
| Session not found | 404 | `unknown_session` |
|
|
136
|
+
| Session already exists | 409 | `session_exists` |
|
|
137
|
+
| Server busy | 503 | `server_busy` |
|
|
138
|
+
| Auth failure | 401 | -- |
|
|
139
|
+
|
|
140
|
+
## License
|
|
141
|
+
|
|
142
|
+
MIT
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "uranus-sdk"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Python SDK for the Uranus video generation serving API"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
authors = [
|
|
7
|
+
{ name = "D-Robotics Large Model Team", email = "vincent.qin@d-robotics.cc" }
|
|
8
|
+
]
|
|
9
|
+
requires-python = ">=3.11"
|
|
10
|
+
dependencies = [
|
|
11
|
+
"requests>=2.31",
|
|
12
|
+
"numpy>=1.24",
|
|
13
|
+
"opencv-python>=4.8",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["uv_build>=0.11.6,<0.12.0"]
|
|
18
|
+
build-backend = "uv_build"
|
|
19
|
+
|
|
20
|
+
[tool.uv]
|
|
21
|
+
dev-dependencies = [
|
|
22
|
+
"pytest>=8.0",
|
|
23
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""Uranus SDK — Python client for the Uranus video generation serving API."""
|
|
2
|
+
|
|
3
|
+
from uranus_sdk.session import UranusSimulationSession, UranusSDKError
|
|
4
|
+
from uranus_sdk.specs import (
|
|
5
|
+
CameraSpec,
|
|
6
|
+
EESpec,
|
|
7
|
+
GripperKeypointOverride,
|
|
8
|
+
SkeletonSpec,
|
|
9
|
+
)
|
|
10
|
+
from uranus_sdk.dataset import load_sample
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"UranusSimulationSession",
|
|
14
|
+
"UranusSDKError",
|
|
15
|
+
"CameraSpec",
|
|
16
|
+
"EESpec",
|
|
17
|
+
"GripperKeypointOverride",
|
|
18
|
+
"SkeletonSpec",
|
|
19
|
+
"load_sample",
|
|
20
|
+
]
|
|
21
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"""Load sample data from HuggingFace Hub via direct HTTP (no huggingface_hub).
|
|
2
|
+
|
|
3
|
+
Usage:
|
|
4
|
+
sample = load_sample("D-Robotics/Uranus-Demo-Data/resolve/main/rc-table30-v1__rc-aloha/000000")
|
|
5
|
+
# sample["create"] → kwargs for UranusSimulationSession
|
|
6
|
+
# sample["steps"] → list of {qpos, robot2world_transforms} dicts
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import numpy as np
|
|
16
|
+
import requests
|
|
17
|
+
|
|
18
|
+
from uranus_sdk.specs import (
|
|
19
|
+
CameraSpec,
|
|
20
|
+
EESpec,
|
|
21
|
+
GripperKeypointOverride,
|
|
22
|
+
SkeletonSpec,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
_HF_BASE = os.environ.get("HF_ENDPOINT", "https://huggingface.co").rstrip("/") + "/datasets"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _hf_url(hf_path: str, filename: str) -> str:
|
|
29
|
+
"""Build a HuggingFace direct-download URL.
|
|
30
|
+
|
|
31
|
+
``hf_path`` is everything after ``huggingface.co/datasets/``, e.g.
|
|
32
|
+
``"D-Robotics/Uranus-Demo-Data/resolve/main/rc-table30-v1__rc-aloha/000000"``.
|
|
33
|
+
"""
|
|
34
|
+
return f"{_HF_BASE}/{hf_path}/{filename}"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _download_text(hf_path: str, filename: str) -> str:
|
|
38
|
+
"""Download a text file from HF Hub."""
|
|
39
|
+
resp = requests.get(_hf_url(hf_path, filename), timeout=60)
|
|
40
|
+
resp.raise_for_status()
|
|
41
|
+
return resp.text
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _download_bytes(hf_path: str, filename: str) -> bytes:
|
|
45
|
+
"""Download a binary file from HF Hub."""
|
|
46
|
+
resp = requests.get(_hf_url(hf_path, filename), timeout=120)
|
|
47
|
+
resp.raise_for_status()
|
|
48
|
+
return resp.content
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def load_sample(
|
|
52
|
+
hf_path: str,
|
|
53
|
+
*,
|
|
54
|
+
num_chunks: int | None = None,
|
|
55
|
+
step_length: int = 4,
|
|
56
|
+
) -> dict[str, Any]:
|
|
57
|
+
"""Load a sample from HuggingFace Hub and build SDK-ready data.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
hf_path: The path after ``huggingface.co/datasets/``, e.g.
|
|
61
|
+
``"D-Robotics/Uranus-Demo-Data/resolve/main/rc-table30-v1__rc-aloha/000000"``.
|
|
62
|
+
num_chunks: Number of step chunks to generate. ``None`` → all available.
|
|
63
|
+
step_length: Frames per step (default 4, = temporal_interval).
|
|
64
|
+
|
|
65
|
+
Returns:
|
|
66
|
+
A dict with two keys:
|
|
67
|
+
|
|
68
|
+
``"create"``: kwargs dict for ``UranusSimulationSession(**sample["create"])``
|
|
69
|
+
``"steps"``: list of ``{"qpos": ndarray, "robot2world_transforms": list}`` dicts
|
|
70
|
+
|
|
71
|
+
Example::
|
|
72
|
+
|
|
73
|
+
sample = load_sample("D-Robotics/Uranus-Demo-Data/resolve/main/rc-table30-v1__rc-aloha/000000")
|
|
74
|
+
with UranusSimulationSession(**sample["create"]) as session:
|
|
75
|
+
for step in sample["steps"]:
|
|
76
|
+
frames = session.step(**step)
|
|
77
|
+
"""
|
|
78
|
+
# --- download meta + temporal ---
|
|
79
|
+
meta = json.loads(_download_text(hf_path, "meta.json"))
|
|
80
|
+
temporal = json.loads(_download_text(hf_path, "temporal.json"))
|
|
81
|
+
|
|
82
|
+
camera_names = list(meta["cameras"])
|
|
83
|
+
cameras = [CameraSpec(name=name) for name in camera_names]
|
|
84
|
+
|
|
85
|
+
# --- download MJCF ---
|
|
86
|
+
mjcf_filename = meta["mjcf_path"]
|
|
87
|
+
# mjcf_path may be relative ("mjcf/robot.xml") — strip any dir prefix
|
|
88
|
+
if "/" in mjcf_filename:
|
|
89
|
+
mjcf_filename = mjcf_filename.split("/")[-1]
|
|
90
|
+
# try with the original path first, then fallback to basename
|
|
91
|
+
try:
|
|
92
|
+
mjcf_xml = _download_text(hf_path, meta["mjcf_path"])
|
|
93
|
+
except requests.HTTPError:
|
|
94
|
+
mjcf_xml = _download_text(hf_path, f"mjcf/{mjcf_filename}")
|
|
95
|
+
|
|
96
|
+
# --- download reference images ---
|
|
97
|
+
ref_cam_images = []
|
|
98
|
+
for name in camera_names:
|
|
99
|
+
img_path = meta["ref_cam_images"][name]
|
|
100
|
+
try:
|
|
101
|
+
ref_cam_images.append(_download_bytes(hf_path, img_path))
|
|
102
|
+
except requests.HTTPError:
|
|
103
|
+
# fallback: strip directory prefix
|
|
104
|
+
basename = img_path.split("/")[-1]
|
|
105
|
+
ref_cam_images.append(_download_bytes(hf_path, f"ref_images/{basename}"))
|
|
106
|
+
|
|
107
|
+
# --- build end_effectors ---
|
|
108
|
+
end_effectors = []
|
|
109
|
+
for ee in meta.get("end_effectors", []):
|
|
110
|
+
end_effectors.append(EESpec(
|
|
111
|
+
object_type=ee["object_type"],
|
|
112
|
+
object_name=ee["object_name"],
|
|
113
|
+
radius_mode=ee["radius_mode"],
|
|
114
|
+
pad_bodies=tuple(ee.get("pad_bodies", [])) if ee.get("pad_bodies") else None,
|
|
115
|
+
radius=float(ee["radius"]) if ee.get("radius") is not None else None,
|
|
116
|
+
sh_correction=np.array(ee["sh_correction"]) if ee.get("sh_correction") else None,
|
|
117
|
+
))
|
|
118
|
+
|
|
119
|
+
# --- build skeleton ---
|
|
120
|
+
skel = meta.get("skeleton", {})
|
|
121
|
+
overrides = tuple(
|
|
122
|
+
GripperKeypointOverride(
|
|
123
|
+
ee_object_type=str(ov["ee_object_type"]),
|
|
124
|
+
ee_object_name=str(ov["ee_object_name"]),
|
|
125
|
+
finger_bodies=tuple(ov["finger_bodies"]),
|
|
126
|
+
closing_axis=int(ov.get("closing_axis", 1)),
|
|
127
|
+
)
|
|
128
|
+
for ov in skel.get("gripper_keypoint_overrides", [])
|
|
129
|
+
)
|
|
130
|
+
skeleton = SkeletonSpec(
|
|
131
|
+
mode=skel.get("mode", "full_tree"),
|
|
132
|
+
chains=tuple(tuple(c) for c in skel.get("chains", [])),
|
|
133
|
+
skip_bodies=tuple(skel.get("skip_bodies", [])),
|
|
134
|
+
gripper_keypoint_overrides=overrides,
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# --- reference frame ---
|
|
138
|
+
states = temporal["step_qpos"]
|
|
139
|
+
ref_frame = states[0]
|
|
140
|
+
ref_qpos = ref_frame["state"]
|
|
141
|
+
r2w = (
|
|
142
|
+
np.array(ref_frame["robot2world_transform"])
|
|
143
|
+
if "robot2world_transform" in ref_frame
|
|
144
|
+
else None
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# --- build create kwargs ---
|
|
148
|
+
create_kwargs = {
|
|
149
|
+
"prompt": meta.get("prompt", ""),
|
|
150
|
+
"mjcf_xml": mjcf_xml,
|
|
151
|
+
"cameras": cameras,
|
|
152
|
+
"ref_cam_images": ref_cam_images,
|
|
153
|
+
"ref_qpos": ref_qpos,
|
|
154
|
+
"robot2world_transform": r2w,
|
|
155
|
+
"end_effectors": end_effectors,
|
|
156
|
+
"skeleton": skeleton,
|
|
157
|
+
"target_size": (int(meta.get("height", 384)), int(meta.get("width", 640))),
|
|
158
|
+
"seed": 1,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
# --- build steps ---
|
|
162
|
+
available = len(states) - 1 # frame 0 is reference
|
|
163
|
+
if num_chunks is None:
|
|
164
|
+
num_chunks = available // step_length
|
|
165
|
+
|
|
166
|
+
steps = []
|
|
167
|
+
for i in range(num_chunks):
|
|
168
|
+
start = i * step_length + 1
|
|
169
|
+
chunk = states[start : start + step_length]
|
|
170
|
+
if len(chunk) < step_length:
|
|
171
|
+
chunk = chunk + [chunk[-1]] * (step_length - len(chunk))
|
|
172
|
+
|
|
173
|
+
qpos = np.array([f["state"] for f in chunk])
|
|
174
|
+
r2w_list = [
|
|
175
|
+
np.array(f["robot2world_transform"])
|
|
176
|
+
if "robot2world_transform" in f
|
|
177
|
+
else None
|
|
178
|
+
for f in chunk
|
|
179
|
+
]
|
|
180
|
+
steps.append({"qpos": qpos, "robot2world_transforms": r2w_list})
|
|
181
|
+
|
|
182
|
+
return {"create": create_kwargs, "steps": steps}
|
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
"""UranusSimulationSession — high-level SDK for the Uranus serving API.
|
|
2
|
+
|
|
3
|
+
Construction triggers ``create``; ``step`` returns RGB frames; ``finish``
|
|
4
|
+
releases the session. Server URL and auth are read from environment
|
|
5
|
+
variables ``URANUS_BASE_URL`` and ``URANUS_API_KEY``.
|
|
6
|
+
|
|
7
|
+
Spec classes (``CameraSpec``, ``EESpec``, ``SkeletonSpec``) are defined
|
|
8
|
+
in :mod:`uranus_sdk.specs` and are structurally compatible with the
|
|
9
|
+
``uranus.skeleton`` Spec classes used by the server.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import base64
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
from dataclasses import asdict
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any, Sequence
|
|
20
|
+
|
|
21
|
+
import cv2
|
|
22
|
+
import numpy as np
|
|
23
|
+
import requests
|
|
24
|
+
|
|
25
|
+
from uranus_sdk.specs import (
|
|
26
|
+
CameraSpec,
|
|
27
|
+
EESpec,
|
|
28
|
+
GripperKeypointOverride,
|
|
29
|
+
SkeletonSpec,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Error
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
class UranusSDKError(RuntimeError):
|
|
38
|
+
"""Raised when the server returns an error or the network fails."""
|
|
39
|
+
|
|
40
|
+
def __init__(
|
|
41
|
+
self,
|
|
42
|
+
message: str,
|
|
43
|
+
*,
|
|
44
|
+
code: str | None = None,
|
|
45
|
+
status_code: int | None = None,
|
|
46
|
+
) -> None:
|
|
47
|
+
super().__init__(message)
|
|
48
|
+
self.code = code
|
|
49
|
+
self.status_code = status_code
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------------------
|
|
53
|
+
# Helpers
|
|
54
|
+
# ---------------------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
def _spec_to_dict(obj: Any) -> Any:
|
|
57
|
+
"""Recursively convert a Spec dataclass (or nested structure) to a JSON-
|
|
58
|
+
safe dict, converting numpy arrays to nested lists."""
|
|
59
|
+
if hasattr(obj, "__dataclass_fields__"):
|
|
60
|
+
return {k: _spec_to_dict(v) for k, v in asdict(obj).items()} # type: ignore[arg-type]
|
|
61
|
+
if isinstance(obj, np.ndarray):
|
|
62
|
+
return obj.tolist()
|
|
63
|
+
if isinstance(obj, (list, tuple)):
|
|
64
|
+
return [_spec_to_dict(v) for v in obj]
|
|
65
|
+
return obj
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _load_image(img: str | Path | bytes | np.ndarray) -> bytes:
|
|
69
|
+
"""Accept a file path, raw bytes, or RGB ndarray and return PNG bytes."""
|
|
70
|
+
if isinstance(img, (str, Path)):
|
|
71
|
+
return Path(img).read_bytes()
|
|
72
|
+
if isinstance(img, np.ndarray):
|
|
73
|
+
return cv2.imencode(".png", cv2.cvtColor(img, cv2.COLOR_RGB2BGR))[1].tobytes()
|
|
74
|
+
return img # assume raw bytes
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# Multipart decode
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
|
|
81
|
+
def _decode_multipart(
|
|
82
|
+
resp: requests.Response,
|
|
83
|
+
) -> tuple[dict[str, Any], dict[str, list[np.ndarray]]]:
|
|
84
|
+
"""Parse a ``multipart/mixed`` step response.
|
|
85
|
+
|
|
86
|
+
Returns ``(info, frames)`` where *info* is the JSON metadata dict and
|
|
87
|
+
*frames* maps ``{camera_name: [BGR ndarray, ...]}``.
|
|
88
|
+
"""
|
|
89
|
+
raw = resp.content
|
|
90
|
+
boundary = raw[: raw.index(b"\r\n")]
|
|
91
|
+
sections = raw.split(b"\r\n" + boundary)[:-1]
|
|
92
|
+
|
|
93
|
+
parts: list[tuple[dict[bytes, bytes], bytes]] = []
|
|
94
|
+
for section in sections:
|
|
95
|
+
header_bytes, _, payload = section.partition(b"\r\n\r\n")
|
|
96
|
+
headers = {}
|
|
97
|
+
for line in header_bytes.split(b"\r\n"):
|
|
98
|
+
if b": " in line:
|
|
99
|
+
k, v = line.split(b": ", 1)
|
|
100
|
+
headers[k] = v
|
|
101
|
+
parts.append((headers, payload))
|
|
102
|
+
|
|
103
|
+
info = json.loads(parts[0][1])
|
|
104
|
+
frames: dict[str, list[np.ndarray]] = {}
|
|
105
|
+
for headers, jpeg in parts[1:]:
|
|
106
|
+
cam = headers[b"X-Camera"].decode()
|
|
107
|
+
img = cv2.imdecode(np.frombuffer(jpeg, dtype=np.uint8), cv2.IMREAD_COLOR)
|
|
108
|
+
frames.setdefault(cam, []).append(img)
|
|
109
|
+
return info, frames
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# UranusSimulationSession
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
class UranusSimulationSession:
|
|
117
|
+
"""High-level SDK for the Uranus video-generation serving API.
|
|
118
|
+
|
|
119
|
+
Construction triggers ``POST /create`` — the session is ready to use as
|
|
120
|
+
soon as ``__init__`` returns. Call :meth:`step` to generate video frames
|
|
121
|
+
and :meth:`finish` (or use ``with``) to release the session.
|
|
122
|
+
|
|
123
|
+
Environment variables:
|
|
124
|
+
``URANUS_BASE_URL`` — server address (default ``http://localhost:8000``)
|
|
125
|
+
``URANUS_API_KEY`` — Bearer token; when set, every request carries
|
|
126
|
+
``Authorization: Bearer <token>``.
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
def __init__(
|
|
130
|
+
self,
|
|
131
|
+
*,
|
|
132
|
+
prompt: str,
|
|
133
|
+
cameras: Sequence[CameraSpec],
|
|
134
|
+
ref_cam_images: Sequence[str | Path | bytes | np.ndarray],
|
|
135
|
+
ref_qpos: list[float] | np.ndarray,
|
|
136
|
+
mjcf_path: str | Path | None = None,
|
|
137
|
+
mjcf_xml: str | None = None,
|
|
138
|
+
robot2world_transform: np.ndarray | None = None,
|
|
139
|
+
end_effectors: Sequence[EESpec] | None = None,
|
|
140
|
+
skeleton: SkeletonSpec | None = None,
|
|
141
|
+
target_size: tuple[int, int] = (384, 640),
|
|
142
|
+
seed: int = 1,
|
|
143
|
+
session_id: str | None = None,
|
|
144
|
+
timeout: float = 600.0,
|
|
145
|
+
) -> None:
|
|
146
|
+
"""Create a new generation session.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
prompt: Natural-language task description.
|
|
150
|
+
cameras: Ordered :class:`CameraSpec` list; order fixes output
|
|
151
|
+
frame ordering for the whole session.
|
|
152
|
+
ref_cam_images: One reference image per camera. Each entry may
|
|
153
|
+
be a file path, raw bytes, or an RGB ``ndarray``.
|
|
154
|
+
ref_qpos: Complete MuJoCo ``qpos`` vector for the reference
|
|
155
|
+
frame (length = ``model.nq``).
|
|
156
|
+
mjcf_path: Path to the MJCF XML file (read and sent inline).
|
|
157
|
+
Mutually exclusive with *mjcf_xml*.
|
|
158
|
+
mjcf_xml: Inline MJCF XML string (use when loading from a URL
|
|
159
|
+
via :func:`load_sample`). Mutually exclusive with *mjcf_path*.
|
|
160
|
+
cameras: Ordered :class:`CameraSpec` list; order fixes output
|
|
161
|
+
frame ordering for the whole session.
|
|
162
|
+
ref_cam_images: One reference image per camera. Each entry may
|
|
163
|
+
be a file path, raw bytes, or an RGB ``ndarray``.
|
|
164
|
+
ref_qpos: Complete MuJoCo ``qpos`` vector for the reference
|
|
165
|
+
frame (length = ``model.nq``).
|
|
166
|
+
robot2world_transform: Optional 4x4 robot-to-world matrix.
|
|
167
|
+
Omit for fixed-base robots (defaults to identity).
|
|
168
|
+
end_effectors: :class:`EESpec` list (aligned with OSS).
|
|
169
|
+
skeleton: :class:`SkeletonSpec` (aligned with OSS).
|
|
170
|
+
target_size: Generation resolution ``(height, width)``.
|
|
171
|
+
seed: RNG seed for reproducible generation.
|
|
172
|
+
session_id: Optional custom session ID; auto-generated if
|
|
173
|
+
omitted.
|
|
174
|
+
timeout: Per-request HTTP timeout in seconds.
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
UranusSDKError: If the create request fails.
|
|
178
|
+
"""
|
|
179
|
+
# --- server config from env ---
|
|
180
|
+
base_url = os.environ.get("URANUS_BASE_URL", "http://localhost:8000")
|
|
181
|
+
api_key = os.environ.get("URANUS_API_KEY")
|
|
182
|
+
self._base_url = base_url.rstrip("/")
|
|
183
|
+
self._timeout = timeout
|
|
184
|
+
self._headers: dict[str, str] = {"Content-Type": "application/json"}
|
|
185
|
+
if api_key:
|
|
186
|
+
self._headers["Authorization"] = f"Bearer {api_key}"
|
|
187
|
+
self._session_id: str | None = None
|
|
188
|
+
|
|
189
|
+
# --- read MJCF ---
|
|
190
|
+
if mjcf_xml is not None:
|
|
191
|
+
pass # use inline string directly
|
|
192
|
+
elif mjcf_path is not None:
|
|
193
|
+
mjcf_xml = Path(mjcf_path).read_text(encoding="utf-8")
|
|
194
|
+
else:
|
|
195
|
+
raise ValueError("Either mjcf_path or mjcf_xml must be provided")
|
|
196
|
+
camera_names = [c.name for c in cameras]
|
|
197
|
+
|
|
198
|
+
# --- ref_qpos -> frame dict ---
|
|
199
|
+
ref_frame: dict[str, Any] = {
|
|
200
|
+
"state": np.asarray(ref_qpos, dtype=np.float64).tolist(),
|
|
201
|
+
}
|
|
202
|
+
if robot2world_transform is not None:
|
|
203
|
+
ref_frame["robot2world_transform"] = np.asarray(
|
|
204
|
+
robot2world_transform, dtype=np.float64
|
|
205
|
+
).tolist()
|
|
206
|
+
|
|
207
|
+
# --- ref_cam_images -> bytes ---
|
|
208
|
+
ref_images_bytes = [_load_image(img) for img in ref_cam_images]
|
|
209
|
+
|
|
210
|
+
# --- Spec -> dict ---
|
|
211
|
+
ee_dicts = [_spec_to_dict(ee) for ee in (end_effectors or [])]
|
|
212
|
+
skel_dict = _spec_to_dict(skeleton) if skeleton is not None else {}
|
|
213
|
+
|
|
214
|
+
# --- payload ---
|
|
215
|
+
payload = {
|
|
216
|
+
"camera_names": camera_names,
|
|
217
|
+
"prompt": prompt,
|
|
218
|
+
"seed": seed,
|
|
219
|
+
"target_size": list(target_size),
|
|
220
|
+
"mjcf_xml": mjcf_xml,
|
|
221
|
+
"end_effectors": ee_dicts,
|
|
222
|
+
"skeleton": skel_dict,
|
|
223
|
+
"ref_qpos": ref_frame,
|
|
224
|
+
}
|
|
225
|
+
body: dict[str, Any] = {
|
|
226
|
+
"payload": payload,
|
|
227
|
+
"attachments": {
|
|
228
|
+
"ref_cam_images": [
|
|
229
|
+
base64.b64encode(img).decode() for img in ref_images_bytes
|
|
230
|
+
]
|
|
231
|
+
},
|
|
232
|
+
}
|
|
233
|
+
if session_id is not None:
|
|
234
|
+
body["session_id"] = session_id
|
|
235
|
+
|
|
236
|
+
# --- POST /create ---
|
|
237
|
+
resp = requests.post(
|
|
238
|
+
f"{self._base_url}/create",
|
|
239
|
+
json=body,
|
|
240
|
+
headers=self._headers,
|
|
241
|
+
timeout=self._timeout,
|
|
242
|
+
)
|
|
243
|
+
self._raise_for_error(resp)
|
|
244
|
+
self._session_id = resp.json()["session_id"]
|
|
245
|
+
|
|
246
|
+
# ------------------------------------------------------------------
|
|
247
|
+
# step
|
|
248
|
+
# ------------------------------------------------------------------
|
|
249
|
+
|
|
250
|
+
def step(
|
|
251
|
+
self,
|
|
252
|
+
qpos: list[list[float]] | np.ndarray,
|
|
253
|
+
*,
|
|
254
|
+
robot2world_transforms: list[np.ndarray | None] | None = None,
|
|
255
|
+
) -> dict[str, list[np.ndarray]]:
|
|
256
|
+
"""Generate video frames for a chunk of motion.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
qpos: Motion frame sequence ``(N, nq)``. Each row is a complete
|
|
260
|
+
MuJoCo ``qpos`` vector. The server rounds ``N`` up to a
|
|
261
|
+
multiple of ``temporal_interval`` (=4).
|
|
262
|
+
robot2world_transforms: Optional per-frame 4x4 transforms.
|
|
263
|
+
``None`` or a frame entry ``None`` → identity matrix.
|
|
264
|
+
|
|
265
|
+
Returns:
|
|
266
|
+
``{camera_name: [RGB uint8 ndarray (H, W, 3), ...]}``
|
|
267
|
+
|
|
268
|
+
Raises:
|
|
269
|
+
UranusSDKError: On server error or if the session was finished.
|
|
270
|
+
"""
|
|
271
|
+
if self._session_id is None:
|
|
272
|
+
raise UranusSDKError("Session not created or already finished")
|
|
273
|
+
|
|
274
|
+
qpos_arr = np.asarray(qpos, dtype=np.float64)
|
|
275
|
+
if qpos_arr.ndim == 1:
|
|
276
|
+
qpos_arr = qpos_arr[np.newaxis, :]
|
|
277
|
+
|
|
278
|
+
frames: list[dict[str, Any]] = []
|
|
279
|
+
for i in range(len(qpos_arr)):
|
|
280
|
+
frame: dict[str, Any] = {"state": qpos_arr[i].tolist()}
|
|
281
|
+
if robot2world_transforms is not None:
|
|
282
|
+
t = robot2world_transforms[i]
|
|
283
|
+
if t is not None:
|
|
284
|
+
frame["robot2world_transform"] = np.asarray(
|
|
285
|
+
t, dtype=np.float64
|
|
286
|
+
).tolist()
|
|
287
|
+
frames.append(frame)
|
|
288
|
+
|
|
289
|
+
payload = {"qpos": frames, "num_step": len(qpos_arr)}
|
|
290
|
+
|
|
291
|
+
resp = requests.post(
|
|
292
|
+
f"{self._base_url}/step",
|
|
293
|
+
json={"session_id": self._session_id, "payload": payload},
|
|
294
|
+
headers=self._headers,
|
|
295
|
+
timeout=self._timeout,
|
|
296
|
+
)
|
|
297
|
+
self._raise_for_error(resp)
|
|
298
|
+
_info, bgr_frames = _decode_multipart(resp)
|
|
299
|
+
|
|
300
|
+
return {
|
|
301
|
+
cam: [cv2.cvtColor(img, cv2.COLOR_BGR2RGB) for img in imgs]
|
|
302
|
+
for cam, imgs in bgr_frames.items()
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
# ------------------------------------------------------------------
|
|
306
|
+
# finish
|
|
307
|
+
# ------------------------------------------------------------------
|
|
308
|
+
|
|
309
|
+
def finish(self) -> None:
|
|
310
|
+
"""Release the session (GPU/CPU state). Idempotent."""
|
|
311
|
+
if self._session_id is not None:
|
|
312
|
+
try:
|
|
313
|
+
requests.delete(
|
|
314
|
+
f"{self._base_url}/sessions/{self._session_id}",
|
|
315
|
+
headers=self._headers,
|
|
316
|
+
timeout=self._timeout,
|
|
317
|
+
)
|
|
318
|
+
except Exception:
|
|
319
|
+
pass
|
|
320
|
+
self._session_id = None
|
|
321
|
+
|
|
322
|
+
# ------------------------------------------------------------------
|
|
323
|
+
# context manager
|
|
324
|
+
# ------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
def __enter__(self) -> UranusSimulationSession:
|
|
327
|
+
return self
|
|
328
|
+
|
|
329
|
+
def __exit__(self, *args: object) -> None:
|
|
330
|
+
self.finish()
|
|
331
|
+
|
|
332
|
+
# ------------------------------------------------------------------
|
|
333
|
+
# error handling
|
|
334
|
+
# ------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
@staticmethod
|
|
337
|
+
def _raise_for_error(resp: requests.Response) -> None:
|
|
338
|
+
if resp.status_code >= 400:
|
|
339
|
+
try:
|
|
340
|
+
data = resp.json()
|
|
341
|
+
err = data.get("error", data)
|
|
342
|
+
msg = err.get("message", resp.text)
|
|
343
|
+
code = err.get("code")
|
|
344
|
+
except Exception:
|
|
345
|
+
msg, code = resp.text, None
|
|
346
|
+
raise UranusSDKError(
|
|
347
|
+
f"HTTP {resp.status_code}: {msg}",
|
|
348
|
+
code=code,
|
|
349
|
+
status_code=resp.status_code,
|
|
350
|
+
)
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Spec classes for rig configuration — structurally compatible with
|
|
2
|
+
``uranus.skeleton.specs``.
|
|
3
|
+
|
|
4
|
+
These dataclasses serialize (via :func:`dataclasses.asdict`) to the same
|
|
5
|
+
JSON shape that the server's ``compat.from_create_payload`` expects.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from dataclasses import dataclass, field
|
|
11
|
+
|
|
12
|
+
import numpy as np
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _require(condition: bool, message: str) -> None:
|
|
16
|
+
if not condition:
|
|
17
|
+
raise ValueError(message)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class CameraSpec:
|
|
22
|
+
"""One XML-defined camera of the rig."""
|
|
23
|
+
|
|
24
|
+
name: str
|
|
25
|
+
|
|
26
|
+
def validate(self) -> None:
|
|
27
|
+
_require(bool(self.name), "camera name must be non-empty")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class EESpec:
|
|
32
|
+
"""One end-effector SH sphere: anchor object + radius source.
|
|
33
|
+
|
|
34
|
+
Fields mirror ``uranus.skeleton.specs.EESpec``.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
object_type: str # "body" | "site"
|
|
38
|
+
object_name: str
|
|
39
|
+
radius_mode: str # "pad_pair" | "fixed"
|
|
40
|
+
pad_bodies: tuple[str, str] | None = None
|
|
41
|
+
radius: float | None = None # fixed mode, meters
|
|
42
|
+
sh_correction: np.ndarray | None = None # 3x3, default identity
|
|
43
|
+
|
|
44
|
+
def validate(self) -> None:
|
|
45
|
+
_require(
|
|
46
|
+
self.object_type in ("body", "site"),
|
|
47
|
+
f"end_effectors[{self.object_name!r}].object_type must be 'body' or 'site'",
|
|
48
|
+
)
|
|
49
|
+
_require(bool(self.object_name), "end_effector object_name must be non-empty")
|
|
50
|
+
if self.radius_mode == "pad_pair":
|
|
51
|
+
_require(
|
|
52
|
+
self.pad_bodies is not None and len(self.pad_bodies) == 2,
|
|
53
|
+
f"end_effectors[{self.object_name!r}] pad_pair requires two pad bodies",
|
|
54
|
+
)
|
|
55
|
+
elif self.radius_mode == "fixed":
|
|
56
|
+
_require(
|
|
57
|
+
self.radius is not None and self.radius > 0.0,
|
|
58
|
+
f"end_effectors[{self.object_name!r}] fixed radius must be > 0",
|
|
59
|
+
)
|
|
60
|
+
else:
|
|
61
|
+
raise ValueError(
|
|
62
|
+
f"end_effectors[{self.object_name!r}].radius_mode must be 'pad_pair' or 'fixed'"
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class GripperKeypointOverride:
|
|
68
|
+
"""Legacy ``render_gripper_keypoints_from_width`` semantics, expressed via
|
|
69
|
+
the rig: the two finger-body keypoints are repositioned to
|
|
70
|
+
``EE_pos +/- EE_rot[:, closing_axis] * width/2``."""
|
|
71
|
+
|
|
72
|
+
ee_object_type: str # "body" | "site"
|
|
73
|
+
ee_object_name: str
|
|
74
|
+
finger_bodies: tuple[str, str]
|
|
75
|
+
closing_axis: int = 1 # column index into the EE rotation
|
|
76
|
+
|
|
77
|
+
def validate(self) -> None:
|
|
78
|
+
_require(self.ee_object_type in ("body", "site"), "gripper override ee_object_type")
|
|
79
|
+
_require(len(self.finger_bodies) == 2, "gripper override needs two finger bodies")
|
|
80
|
+
_require(0 <= self.closing_axis < 3, "closing_axis must be 0/1/2")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class SkeletonSpec:
|
|
85
|
+
"""Keypoint topology for rendering."""
|
|
86
|
+
|
|
87
|
+
mode: str = "full_tree" # "chains" | "full_tree"
|
|
88
|
+
chains: tuple[tuple[str, ...], ...] = ()
|
|
89
|
+
skip_bodies: tuple[str, ...] = ()
|
|
90
|
+
gripper_keypoint_overrides: tuple[GripperKeypointOverride, ...] = ()
|
|
91
|
+
|
|
92
|
+
def validate(self) -> None:
|
|
93
|
+
if self.mode not in ("chains", "full_tree"):
|
|
94
|
+
raise ValueError(f"skeleton.mode must be 'chains' or 'full_tree', got {self.mode!r}")
|
|
95
|
+
if self.mode == "chains":
|
|
96
|
+
_require(len(self.chains) > 0, "skeleton chains must be non-empty in 'chains' mode")
|
|
97
|
+
for ov in self.gripper_keypoint_overrides:
|
|
98
|
+
ov.validate()
|