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.
@@ -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()