Publish Cosmos Scenario Studio static portfolio demo
Browse files- .dockerignore +14 -0
- .gitignore +12 -0
- Dockerfile +22 -0
- LICENSE +22 -0
- README.md +205 -4
- app.mjs +437 -0
- fake_nim.py +69 -0
- fake_reason.py +67 -0
- fixtures/demo-world.mp4 +0 -0
- fixtures/warehouse_baseline_failure.metrics.json +18 -0
- index.html +176 -17
- isaac_adapter/__init__.py +2 -0
- isaac_adapter/build_warehouse_scene.py +147 -0
- isaac_adapter/calibration.py +100 -0
- isaac_adapter/client.py +28 -0
- isaac_adapter/contracts.py +112 -0
- isaac_adapter/fake_runtime.py +53 -0
- isaac_adapter/runtime.py +180 -0
- model.mjs +113 -0
- model.test.mjs +27 -0
- run_warehouse_acceptance.py +63 -0
- scene_mapping.example.json +21 -0
- scene_mapping.warehouse.json +29 -0
- server.py +561 -0
- styles.css +129 -0
- test_server.py +101 -0
.dockerignore
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
.git
|
| 2 |
+
.github
|
| 3 |
+
__pycache__
|
| 4 |
+
*.py[cod]
|
| 5 |
+
.pytest_cache
|
| 6 |
+
.venv
|
| 7 |
+
.env
|
| 8 |
+
.DS_Store
|
| 9 |
+
outputs
|
| 10 |
+
artifacts
|
| 11 |
+
remote.env
|
| 12 |
+
*.pem
|
| 13 |
+
*.key
|
| 14 |
+
|
.gitignore
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
.pytest_cache/
|
| 4 |
+
.venv/
|
| 5 |
+
.env
|
| 6 |
+
.DS_Store
|
| 7 |
+
outputs/
|
| 8 |
+
artifacts/
|
| 9 |
+
remote.env
|
| 10 |
+
*.pem
|
| 11 |
+
*.key
|
| 12 |
+
|
Dockerfile
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.13-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 \
|
| 4 |
+
PYTHONUNBUFFERED=1 \
|
| 5 |
+
COSMOS_STUDIO_HOST=0.0.0.0 \
|
| 6 |
+
COSMOS_STUDIO_PORT=8090
|
| 7 |
+
|
| 8 |
+
WORKDIR /app
|
| 9 |
+
|
| 10 |
+
RUN groupadd --system studio && useradd --system --gid studio --home /app studio
|
| 11 |
+
|
| 12 |
+
COPY --chown=studio:studio . /app
|
| 13 |
+
RUN mkdir -p /app/outputs/isaac && chown -R studio:studio /app/outputs
|
| 14 |
+
|
| 15 |
+
USER studio
|
| 16 |
+
EXPOSE 8090
|
| 17 |
+
|
| 18 |
+
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
|
| 19 |
+
CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8090/', timeout=3)" || exit 1
|
| 20 |
+
|
| 21 |
+
CMD ["python", "server.py"]
|
| 22 |
+
|
LICENSE
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2026 Junbin Kuo
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
| 22 |
+
|
README.md
CHANGED
|
@@ -1,10 +1,211 @@
|
|
| 1 |
---
|
| 2 |
title: Cosmos Scenario Studio
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: static
|
|
|
|
| 7 |
pinned: false
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Cosmos Scenario Studio
|
| 3 |
+
emoji: 🌌
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: gray
|
| 6 |
sdk: static
|
| 7 |
+
app_file: index.html
|
| 8 |
pinned: false
|
| 9 |
+
license: mit
|
| 10 |
+
short_description: Physical AI scenarios for Cosmos and Isaac validation
|
| 11 |
+
tags:
|
| 12 |
+
- nvidia
|
| 13 |
+
- cosmos
|
| 14 |
+
- isaac-sim
|
| 15 |
+
- physical-ai
|
| 16 |
+
- synthetic-data
|
| 17 |
---
|
| 18 |
|
| 19 |
+
# Cosmos Scenario Studio
|
| 20 |
+
|
| 21 |
+
[Live Hugging Face Space](https://huggingface.co/spaces/junbin666/cosmos-scenario-studio)
|
| 22 |
+
· [GitHub repository](https://github.com/jjfishjj/cosmos-scenario-studio)
|
| 23 |
+
|
| 24 |
+
Cosmos Scenario Studio turns Physical AI scenarios into reproducible NVIDIA
|
| 25 |
+
Cosmos video-generation jobs, Cosmos Reason2 safety evaluations, and Isaac Sim
|
| 26 |
+
validation work packages. It runs locally with only Python's standard library
|
| 27 |
+
and ships as a small CPU web container; real simulation remains on a separate
|
| 28 |
+
Linux RTX worker.
|
| 29 |
+
|
| 30 |
+
The public Hugging Face deployment uses the free Static Space runtime. Its
|
| 31 |
+
offline scenario composer, deterministic variants, animated preview, and JSON
|
| 32 |
+
manifest export work in-browser. Cosmos NIM, Reason2, and Isaac adapter actions
|
| 33 |
+
are intentionally disabled in the hosted UI because they require the Python
|
| 34 |
+
proxy and separately configured NVIDIA runtimes. Clone the GitHub repository to
|
| 35 |
+
exercise those integrations.
|
| 36 |
+
|
| 37 |
+
> **Evidence boundary:** the public Space proves the scenario-to-contract
|
| 38 |
+
> workflow. Local fixture videos and the contract worker are not Cosmos model
|
| 39 |
+
> output or Isaac Sim/PhysX evidence. Real generation and physics validation
|
| 40 |
+
> require separately configured NVIDIA runtimes.
|
| 41 |
+
|
| 42 |
+
An offline-first experiment planner for NVIDIA Cosmos-Predict2.5. It turns a
|
| 43 |
+
Physical AI task into deterministic world variants, previews risk coverage,
|
| 44 |
+
exports a dataset manifest, and can submit each request to a self-hosted Cosmos
|
| 45 |
+
NIM `/v1/infer` endpoint.
|
| 46 |
+
|
| 47 |
+
## Run the complete app
|
| 48 |
+
|
| 49 |
+
```bash
|
| 50 |
+
cd cosmos-scenario-studio
|
| 51 |
+
python3 server.py
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
+
Open <http://127.0.0.1:8090>.
|
| 55 |
+
|
| 56 |
+
### Docker
|
| 57 |
+
|
| 58 |
+
```bash
|
| 59 |
+
docker compose up --build
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
The Studio is then available at <http://127.0.0.1:8090>. See
|
| 63 |
+
[`DEPLOYMENT.md`](DEPLOYMENT.md) for GitHub Actions, GHCR publishing, secrets,
|
| 64 |
+
and production container commands.
|
| 65 |
+
|
| 66 |
+
The default **Local Demo** mode does not require a GPU or API key. The **Cosmos
|
| 67 |
+
NIM** mode submits background jobs through the local Python proxy, polls their
|
| 68 |
+
status, retries temporary failures, decodes `b64_video`, and saves generated
|
| 69 |
+
MP4 files under `outputs/`. The API key remains in process memory and is never
|
| 70 |
+
persisted.
|
| 71 |
+
|
| 72 |
+
### End-to-end test without a GPU
|
| 73 |
+
|
| 74 |
+
Start the bundled NIM-compatible fixture server in another terminal:
|
| 75 |
+
|
| 76 |
+
```bash
|
| 77 |
+
python3 fake_nim.py --fail-first 1
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
For automatic Physical AI scoring, start the OpenAI-compatible Reason2 fixture
|
| 81 |
+
in a third terminal:
|
| 82 |
+
|
| 83 |
+
```bash
|
| 84 |
+
python3 fake_reason.py
|
| 85 |
+
```
|
| 86 |
+
|
| 87 |
+
Then select **Cosmos NIM**, click **Check connection**, generate an experiment,
|
| 88 |
+
and submit the batch. `--fail-first 1` proves that the proxy retries a temporary
|
| 89 |
+
503 response before completing.
|
| 90 |
+
|
| 91 |
+
For a real deployment, keep the default endpoint if the NIM container is
|
| 92 |
+
available at `http://127.0.0.1:8000/v1/infer`, or enter the remote HTTPS NIM
|
| 93 |
+
endpoint and its bearer token.
|
| 94 |
+
|
| 95 |
+
## Cosmos Reason2 and Isaac Sim
|
| 96 |
+
|
| 97 |
+
Successful videos can be evaluated through a Cosmos Reason2 VLM endpoint at
|
| 98 |
+
`/v1/chat/completions`. The proxy sends the local MP4 as a base64 video data URI
|
| 99 |
+
and requests four structured scores:
|
| 100 |
+
|
| 101 |
+
- collision risk and danger severity (higher is worse)
|
| 102 |
+
- physics plausibility and task success (higher is better)
|
| 103 |
+
- an evidence-grounded observation and a recommended Isaac Sim test
|
| 104 |
+
|
| 105 |
+
The resulting edge-case priority is weighted toward collision and danger. The
|
| 106 |
+
**Isaac work package** ZIP contains:
|
| 107 |
+
|
| 108 |
+
- `cosmos_manifest.json` — prompts, seeds, and generation settings
|
| 109 |
+
- `domain_randomization.json` — Reason2-priority-scaled lighting, camera,
|
| 110 |
+
object-pose, friction, payload, and damping ranges
|
| 111 |
+
- `policy_validation.json` — ranked episodes, next-test recommendations, and
|
| 112 |
+
fail-closed acceptance thresholds
|
| 113 |
+
- `scene_mapping.example.json` — USD prim, collision sensor, clearance radius,
|
| 114 |
+
task tolerance, and controller settings for the adapter
|
| 115 |
+
|
| 116 |
+
## Isaac Sim Scene Adapter
|
| 117 |
+
|
| 118 |
+
The app now queues validation runs and exposes a worker protocol for Isaac Sim.
|
| 119 |
+
The adapter reads the ZIP, resolves the configured USD prims, applies episode
|
| 120 |
+
randomization, runs the Franka pick-and-place controller, observes contact and
|
| 121 |
+
clearance, then returns success rate, collision rate, minimum clearance, and
|
| 122 |
+
the final pass/fail decision to the browser.
|
| 123 |
+
|
| 124 |
+
First validate the complete protocol without Isaac Sim or a GPU:
|
| 125 |
+
|
| 126 |
+
```bash
|
| 127 |
+
python3 -m isaac_adapter.fake_runtime
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
Choose **Contract worker** in the UI and click **Run Scene Adapter**. This
|
| 131 |
+
worker exercises package download, atomic run claiming, episode telemetry, and
|
| 132 |
+
result reporting; its deterministic physics metrics are a test fixture rather
|
| 133 |
+
than a simulator result.
|
| 134 |
+
|
| 135 |
+
To run the real adapter, create a scene mapping from
|
| 136 |
+
`scene_mapping.example.json`, replacing the USD file and prim paths with those
|
| 137 |
+
in your scene. From this project directory, launch it with Isaac Sim 5.1's
|
| 138 |
+
Python runtime:
|
| 139 |
+
|
| 140 |
+
```bash
|
| 141 |
+
/path/to/isaac-sim/python.sh -m isaac_adapter.runtime \
|
| 142 |
+
--server http://127.0.0.1:8090 \
|
| 143 |
+
--mapping /absolute/path/to/scene_mapping.json \
|
| 144 |
+
--headless
|
| 145 |
+
```
|
| 146 |
+
|
| 147 |
+
Choose **Isaac Sim 5.1** in the UI before starting the run. Omit `--mapping`
|
| 148 |
+
only when the mapping embedded in the work package already matches the scene.
|
| 149 |
+
Use `--once` when a worker should claim one queued run and exit.
|
| 150 |
+
|
| 151 |
+
The current reference adapter targets Isaac Sim 5.1. It must be launched by
|
| 152 |
+
Isaac's bundled Python, and the referenced USD scene and robot prims must exist.
|
| 153 |
+
The included contract worker is the local/CI substitute for machines without
|
| 154 |
+
an Isaac Sim installation.
|
| 155 |
+
|
| 156 |
+
### Warehouse Franka physical acceptance
|
| 157 |
+
|
| 158 |
+
On a Linux x86_64 workstation with Isaac Sim 5.1 and a supported NVIDIA RTX
|
| 159 |
+
GPU, export an Isaac work package from the Studio, then run:
|
| 160 |
+
|
| 161 |
+
```bash
|
| 162 |
+
python3 run_warehouse_acceptance.py \
|
| 163 |
+
--isaac-root /opt/isaac-sim \
|
| 164 |
+
--package /absolute/path/to/isaac-work-package.zip \
|
| 165 |
+
--episodes 3
|
| 166 |
+
```
|
| 167 |
+
|
| 168 |
+
The launcher uses Isaac's own `python.sh` to build
|
| 169 |
+
`artifacts/warehouse_franka.usd`, writes the calibrated mapping beside it, runs
|
| 170 |
+
the real Franka episodes, and saves `artifacts/warehouse_franka.metrics.json`.
|
| 171 |
+
Add `--gui` to inspect the scene in the viewport. Collision sensors cover
|
| 172 |
+
`panda_link0` through `panda_link7` plus `panda_hand`; task success, collision,
|
| 173 |
+
and minimum hazard clearance are reported independently.
|
| 174 |
+
|
| 175 |
+
After a real run, generate a fail-closed calibration diagnosis with:
|
| 176 |
+
|
| 177 |
+
```bash
|
| 178 |
+
python3 -m isaac_adapter.calibration \
|
| 179 |
+
--metrics artifacts/warehouse_franka.metrics.json \
|
| 180 |
+
--mapping artifacts/warehouse_franka.mapping.json \
|
| 181 |
+
--output artifacts/warehouse_franka.calibration.json
|
| 182 |
+
```
|
| 183 |
+
|
| 184 |
+
The report distinguishes controller timeouts, placement errors, colliding
|
| 185 |
+
Franka links, and unsafe clearance. It can propose end-effector-offset test
|
| 186 |
+
candidates, but never relaxes collision or clearance acceptance thresholds.
|
| 187 |
+
|
| 188 |
+
macOS and Apple Silicon can edit and test the work-package contract, but cannot
|
| 189 |
+
run Isaac Sim 5.1/PhysX acceptance. Use the contract worker locally and execute
|
| 190 |
+
the command above on a Linux RTX machine for real physics metrics.
|
| 191 |
+
|
| 192 |
+
## Test
|
| 193 |
+
|
| 194 |
+
```bash
|
| 195 |
+
node --test model.test.mjs
|
| 196 |
+
python3 -m unittest -v test_server.py
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
GitHub Actions runs these checks on every pull request, then builds and smoke
|
| 200 |
+
tests the container. Publishing a GitHub Release produces a versioned image in
|
| 201 |
+
GitHub Container Registry.
|
| 202 |
+
|
| 203 |
+
## Cosmos mapping
|
| 204 |
+
|
| 205 |
+
- Each variant contains the official Predict2.5 request fields: prompt,
|
| 206 |
+
negative prompt, seed, guidance scale, steps, resolution, and frame count.
|
| 207 |
+
- The exported manifest is designed as an experiment contract for later
|
| 208 |
+
curation, Isaac Sim validation, or policy-training pipelines.
|
| 209 |
+
- NIM calls default to a 900-second timeout because video generation commonly
|
| 210 |
+
takes several minutes. HTTP 408, 425, 429, and temporary 5xx responses are
|
| 211 |
+
retried with exponential backoff.
|
app.mjs
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { PRESETS, buildManifest, buildPrompt, riskScore, validateScenario } from "./model.mjs";
|
| 2 |
+
|
| 3 |
+
const form = document.querySelector("#scenario-form");
|
| 4 |
+
const queue = document.querySelector("#queue");
|
| 5 |
+
const emptyQueue = document.querySelector("#empty-queue");
|
| 6 |
+
const errorBox = document.querySelector("#form-error");
|
| 7 |
+
const runButton = document.querySelector("#run-batch");
|
| 8 |
+
const downloadButton = document.querySelector("#download");
|
| 9 |
+
const staticSpace = true;
|
| 10 |
+
let manifest = null;
|
| 11 |
+
let mode = "mock";
|
| 12 |
+
let jobs = [];
|
| 13 |
+
let polling = false;
|
| 14 |
+
let evaluations = [];
|
| 15 |
+
let evaluating = false;
|
| 16 |
+
let isaacPackage = null;
|
| 17 |
+
let isaacRun = null;
|
| 18 |
+
|
| 19 |
+
function formValues() {
|
| 20 |
+
return Object.fromEntries(new FormData(form).entries());
|
| 21 |
+
}
|
| 22 |
+
|
| 23 |
+
function escapeHtml(value) {
|
| 24 |
+
return String(value).replace(/[&<>'"]/g, (character) => ({ "&": "&", "<": "<", ">": ">", "'": "'", '"': """ }[character]));
|
| 25 |
+
}
|
| 26 |
+
|
| 27 |
+
function fillForm(values) {
|
| 28 |
+
Object.entries(values).forEach(([key, value]) => {
|
| 29 |
+
if (form.elements[key]) form.elements[key].value = value;
|
| 30 |
+
});
|
| 31 |
+
renderDraft();
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
function saveDraft() {
|
| 35 |
+
localStorage.setItem("cosmos-scenario-draft", JSON.stringify(formValues()));
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
function renderDraft() {
|
| 39 |
+
const values = formValues();
|
| 40 |
+
document.querySelector("#world-title").textContent = values.name || "未命名世界";
|
| 41 |
+
document.querySelector("#prompt-output").textContent = buildPrompt({
|
| 42 |
+
...values,
|
| 43 |
+
environment: values.environment || "an undefined environment",
|
| 44 |
+
robot: values.robot || "a physical AI robot",
|
| 45 |
+
task: values.task || "complete its assigned task",
|
| 46 |
+
});
|
| 47 |
+
document.querySelector("#seed-label").textContent = `SEED ${values.seed || 7}`;
|
| 48 |
+
document.querySelector("#frame-count").textContent = `${values.frames || 121} FRAMES`;
|
| 49 |
+
const score = riskScore(values);
|
| 50 |
+
document.querySelector("#risk-score").textContent = `${score}%`;
|
| 51 |
+
document.querySelector("#risk-bar").style.width = `${score}%`;
|
| 52 |
+
document.querySelector("#risk-copy").textContent = score >= 70 ? "高價值 edge case" : score >= 50 ? "中度壓力情境" : "基準情境";
|
| 53 |
+
saveDraft();
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
function setPreset(name) {
|
| 57 |
+
fillForm({ ...PRESETS[name], variants: 4, seed: 7, frames: 121, resolution: "720" });
|
| 58 |
+
document.querySelectorAll("[data-preset]").forEach((button) => button.classList.toggle("active", button.dataset.preset === name));
|
| 59 |
+
errorBox.hidden = true;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
function statusLabel(status) {
|
| 63 |
+
return { ready: "READY", queued: "QUEUED", running: "GENERATING", retrying: "RETRYING", succeeded: "COMPLETE", complete: "COMPLETE", failed: "FAILED" }[status] || status.toUpperCase();
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function renderQueue(statuses = []) {
|
| 67 |
+
if (!manifest) return;
|
| 68 |
+
queue.innerHTML = manifest.variants.map((variant, index) => {
|
| 69 |
+
const item = statuses[index];
|
| 70 |
+
const status = typeof item === "string" ? item : item?.status || "ready";
|
| 71 |
+
const attempt = item?.attempts ? `<em>${item.message || ""} · attempt ${item.attempts}</em>` : "";
|
| 72 |
+
return `<article class="queue-item ${status}" data-id="${variant.id}">
|
| 73 |
+
<span class="queue-index">${String(index + 1).padStart(2, "0")}</span>
|
| 74 |
+
<div><strong>${variant.id}</strong><small>seed ${variant.seed} · ${variant.request.resolution}p · ${variant.request.num_output_frames}f</small>${attempt}</div>
|
| 75 |
+
<b>${statusLabel(status)}</b>
|
| 76 |
+
</article>`;
|
| 77 |
+
}).join("");
|
| 78 |
+
emptyQueue.hidden = true;
|
| 79 |
+
queue.hidden = false;
|
| 80 |
+
document.querySelector("#queue-count").textContent = `${manifest.variants.length} WORLDS`;
|
| 81 |
+
runButton.disabled = false;
|
| 82 |
+
downloadButton.disabled = false;
|
| 83 |
+
}
|
| 84 |
+
|
| 85 |
+
async function runMock() {
|
| 86 |
+
const states = manifest.variants.map(() => "ready");
|
| 87 |
+
runButton.disabled = true;
|
| 88 |
+
for (let index = 0; index < states.length; index += 1) {
|
| 89 |
+
states[index] = "running";
|
| 90 |
+
renderQueue(states);
|
| 91 |
+
await new Promise((resolve) => setTimeout(resolve, 360));
|
| 92 |
+
states[index] = "complete";
|
| 93 |
+
renderQueue(states);
|
| 94 |
+
}
|
| 95 |
+
runButton.disabled = false;
|
| 96 |
+
runButton.textContent = "再次執行";
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
function connectionConfig() {
|
| 100 |
+
return {
|
| 101 |
+
endpoint: document.querySelector("#endpoint").value.trim(),
|
| 102 |
+
api_key: document.querySelector("#api-key").value,
|
| 103 |
+
max_retries: Number(document.querySelector("#max-retries").value),
|
| 104 |
+
timeout_seconds: Number(document.querySelector("#timeout-seconds").value),
|
| 105 |
+
};
|
| 106 |
+
}
|
| 107 |
+
|
| 108 |
+
async function api(path, options = {}) {
|
| 109 |
+
const response = await fetch(path, {
|
| 110 |
+
...options,
|
| 111 |
+
headers: { "Content-Type": "application/json", ...(options.headers || {}) },
|
| 112 |
+
});
|
| 113 |
+
const data = await response.json().catch(() => ({}));
|
| 114 |
+
if (!response.ok) throw new Error(data.error || `${response.status} ${response.statusText}`);
|
| 115 |
+
return data;
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
async function checkNim() {
|
| 119 |
+
const button = document.querySelector("#check-nim");
|
| 120 |
+
const label = document.querySelector("#nim-health-label");
|
| 121 |
+
const dot = document.querySelector("#nim-health-dot");
|
| 122 |
+
button.disabled = true;
|
| 123 |
+
label.textContent = "檢查中…";
|
| 124 |
+
dot.className = "";
|
| 125 |
+
try {
|
| 126 |
+
const result = await api("/api/nim/health", { method: "POST", body: JSON.stringify(connectionConfig()) });
|
| 127 |
+
dot.className = result.ready ? "ready" : "failed";
|
| 128 |
+
label.textContent = result.ready ? `READY · ${result.latency_ms}ms` : "NOT READY";
|
| 129 |
+
} catch (error) {
|
| 130 |
+
dot.className = "failed";
|
| 131 |
+
label.textContent = "連線失敗";
|
| 132 |
+
errorBox.textContent = `NIM 健康檢查失敗:${error.message}`;
|
| 133 |
+
errorBox.hidden = false;
|
| 134 |
+
} finally {
|
| 135 |
+
button.disabled = false;
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
async function createJob(variant) {
|
| 140 |
+
return api("/api/jobs", {
|
| 141 |
+
method: "POST",
|
| 142 |
+
body: JSON.stringify({ ...connectionConfig(), variant_id: variant.id, payload: variant.request }),
|
| 143 |
+
});
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
async function pollJobs() {
|
| 147 |
+
polling = true;
|
| 148 |
+
while (polling) {
|
| 149 |
+
jobs = await Promise.all(jobs.map((job) => job.status === "succeeded" || job.status === "failed" ? job : api(`/api/jobs/${job.id}`)));
|
| 150 |
+
renderQueue(jobs);
|
| 151 |
+
const done = jobs.every((job) => ["succeeded", "failed"].includes(job.status));
|
| 152 |
+
if (done) break;
|
| 153 |
+
await new Promise((resolve) => setTimeout(resolve, 800));
|
| 154 |
+
}
|
| 155 |
+
polling = false;
|
| 156 |
+
runButton.disabled = false;
|
| 157 |
+
const failed = jobs.filter((job) => job.status === "failed");
|
| 158 |
+
if (failed.length) {
|
| 159 |
+
errorBox.textContent = `${failed.length} 個世界生成失敗。點擊失敗項目可重新送出。`;
|
| 160 |
+
errorBox.hidden = false;
|
| 161 |
+
} else {
|
| 162 |
+
runButton.textContent = "再次送往 Cosmos NIM";
|
| 163 |
+
showVideo(jobs.find((job) => job.output_url));
|
| 164 |
+
document.querySelector("#evaluate-batch").disabled = false;
|
| 165 |
+
if (document.querySelector("#auto-evaluate").checked) await runEvaluations();
|
| 166 |
+
}
|
| 167 |
+
}
|
| 168 |
+
|
| 169 |
+
function showVideo(job) {
|
| 170 |
+
if (!job?.output_url) return;
|
| 171 |
+
const player = document.querySelector("#result-player");
|
| 172 |
+
const video = document.querySelector("#generated-video");
|
| 173 |
+
document.querySelector("#result-title").textContent = `${job.variant_id} · GENERATED WORLD`;
|
| 174 |
+
document.querySelector("#open-video").href = job.output_url;
|
| 175 |
+
video.src = job.output_url;
|
| 176 |
+
player.hidden = false;
|
| 177 |
+
video.load();
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
async function runNim() {
|
| 181 |
+
const endpoint = document.querySelector("#endpoint").value.trim();
|
| 182 |
+
if (!endpoint) {
|
| 183 |
+
errorBox.textContent = "請設定 Cosmos NIM endpoint。";
|
| 184 |
+
errorBox.hidden = false;
|
| 185 |
+
return;
|
| 186 |
+
}
|
| 187 |
+
runButton.disabled = true;
|
| 188 |
+
errorBox.hidden = true;
|
| 189 |
+
evaluations = [];
|
| 190 |
+
isaacPackage = null;
|
| 191 |
+
isaacRun = null;
|
| 192 |
+
document.querySelector("#ranking").hidden = true;
|
| 193 |
+
document.querySelector("#ranking-empty").hidden = false;
|
| 194 |
+
document.querySelector("#export-isaac").disabled = true;
|
| 195 |
+
document.querySelector("#result-player").hidden = true;
|
| 196 |
+
try {
|
| 197 |
+
jobs = await Promise.all(manifest.variants.map(createJob));
|
| 198 |
+
renderQueue(jobs);
|
| 199 |
+
await pollJobs();
|
| 200 |
+
} catch (error) {
|
| 201 |
+
errorBox.textContent = `Proxy 建立任務失敗:${error.message}。請使用 python3 server.py 啟動 Studio。`;
|
| 202 |
+
errorBox.hidden = false;
|
| 203 |
+
runButton.disabled = false;
|
| 204 |
+
}
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
function reasonConfig() {
|
| 208 |
+
return {
|
| 209 |
+
reason_endpoint: document.querySelector("#reason-endpoint").value.trim(),
|
| 210 |
+
api_key: document.querySelector("#reason-api-key").value,
|
| 211 |
+
timeout_seconds: 300,
|
| 212 |
+
};
|
| 213 |
+
}
|
| 214 |
+
|
| 215 |
+
function renderRanking() {
|
| 216 |
+
const ranking = document.querySelector("#ranking");
|
| 217 |
+
const empty = document.querySelector("#ranking-empty");
|
| 218 |
+
const successful = evaluations.filter((item) => item.status === "succeeded" && item.scores)
|
| 219 |
+
.sort((a, b) => b.scores.priority - a.scores.priority);
|
| 220 |
+
if (!successful.length) return;
|
| 221 |
+
ranking.innerHTML = successful.map((item, index) => `<article class="rank-row">
|
| 222 |
+
<b>#${index + 1}</b>
|
| 223 |
+
<div><h3>${escapeHtml(item.variant_id)}</h3><div class="score-grid">
|
| 224 |
+
<span>COLLISION<strong>${item.scores.collision_risk}</strong></span>
|
| 225 |
+
<span>PHYSICS<strong>${item.scores.physics_plausibility}</strong></span>
|
| 226 |
+
<span>SUCCESS<strong>${item.scores.task_success}</strong></span>
|
| 227 |
+
<span>DANGER<strong>${item.scores.danger_severity}</strong></span>
|
| 228 |
+
</div><p class="rank-reason">${escapeHtml(item.scores.rationale)} Next: ${escapeHtml(item.scores.recommended_test)}</p></div>
|
| 229 |
+
<div class="priority">${item.scores.priority}<small>PRIORITY</small></div>
|
| 230 |
+
</article>`).join("");
|
| 231 |
+
empty.hidden = true;
|
| 232 |
+
ranking.hidden = false;
|
| 233 |
+
document.querySelector("#export-isaac").disabled = false;
|
| 234 |
+
document.querySelector("#run-isaac").disabled = false;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
async function runEvaluations() {
|
| 238 |
+
const succeeded = jobs.filter((job) => job.status === "succeeded");
|
| 239 |
+
if (!succeeded.length || evaluating) return;
|
| 240 |
+
if (!reasonConfig().reason_endpoint) {
|
| 241 |
+
errorBox.textContent = "請設定 Cosmos Reason2 endpoint。";
|
| 242 |
+
errorBox.hidden = false;
|
| 243 |
+
return;
|
| 244 |
+
}
|
| 245 |
+
evaluating = true;
|
| 246 |
+
const evaluateButton = document.querySelector("#evaluate-batch");
|
| 247 |
+
evaluateButton.disabled = true;
|
| 248 |
+
evaluateButton.textContent = "Reason2 分析中…";
|
| 249 |
+
try {
|
| 250 |
+
evaluations = await Promise.all(succeeded.map((job) => api("/api/evaluations", {
|
| 251 |
+
method: "POST",
|
| 252 |
+
body: JSON.stringify({ ...reasonConfig(), job_id: job.id, scenario: formValues() }),
|
| 253 |
+
})));
|
| 254 |
+
while (evaluations.some((item) => !["succeeded", "failed"].includes(item.status))) {
|
| 255 |
+
await new Promise((resolve) => setTimeout(resolve, 700));
|
| 256 |
+
evaluations = await Promise.all(evaluations.map((item) => ["succeeded", "failed"].includes(item.status) ? item : api(`/api/evaluations/${item.id}`)));
|
| 257 |
+
}
|
| 258 |
+
renderRanking();
|
| 259 |
+
const failed = evaluations.filter((item) => item.status === "failed");
|
| 260 |
+
if (failed.length) {
|
| 261 |
+
errorBox.textContent = `${failed.length} 個 Reason2 評分失敗:${failed[0].error}`;
|
| 262 |
+
errorBox.hidden = false;
|
| 263 |
+
}
|
| 264 |
+
} catch (error) {
|
| 265 |
+
errorBox.textContent = `Reason2 評分無法啟動:${error.message}`;
|
| 266 |
+
errorBox.hidden = false;
|
| 267 |
+
} finally {
|
| 268 |
+
evaluating = false;
|
| 269 |
+
evaluateButton.disabled = false;
|
| 270 |
+
evaluateButton.textContent = "重新用 Reason2 評分";
|
| 271 |
+
}
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
async function createIsaacPackage(download = false) {
|
| 275 |
+
const button = document.querySelector("#export-isaac");
|
| 276 |
+
button.disabled = true;
|
| 277 |
+
button.textContent = "建立 Isaac 工作包…";
|
| 278 |
+
try {
|
| 279 |
+
const result = await api("/api/isaac/export", {
|
| 280 |
+
method: "POST",
|
| 281 |
+
body: JSON.stringify({ manifest, evaluations }),
|
| 282 |
+
});
|
| 283 |
+
isaacPackage = result;
|
| 284 |
+
document.querySelector("#run-isaac").disabled = false;
|
| 285 |
+
if (download) {
|
| 286 |
+
const link = document.createElement("a");
|
| 287 |
+
link.href = result.download_url;
|
| 288 |
+
link.download = result.filename;
|
| 289 |
+
link.click();
|
| 290 |
+
}
|
| 291 |
+
button.textContent = "Isaac 工作包已建立";
|
| 292 |
+
return result;
|
| 293 |
+
} catch (error) {
|
| 294 |
+
errorBox.textContent = `Isaac 工作包建立失敗:${error.message}`;
|
| 295 |
+
errorBox.hidden = false;
|
| 296 |
+
button.textContent = "下載 Isaac 工作包";
|
| 297 |
+
} finally {
|
| 298 |
+
button.disabled = false;
|
| 299 |
+
}
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
function renderIsaacResult(run) {
|
| 303 |
+
const panel = document.querySelector("#isaac-result");
|
| 304 |
+
if (!run.result) {
|
| 305 |
+
panel.innerHTML = `<article><span>RUN STATUS</span><strong>${escapeHtml(run.status.toUpperCase())}</strong></article><article><span>MESSAGE</span><strong>${escapeHtml(run.message)}</strong></article>`;
|
| 306 |
+
panel.hidden = false;
|
| 307 |
+
return;
|
| 308 |
+
}
|
| 309 |
+
const result = run.result;
|
| 310 |
+
panel.innerHTML = `
|
| 311 |
+
<article class="${escapeHtml(result.status)}"><span>VALIDATION</span><strong>${escapeHtml(result.status.toUpperCase())}</strong></article>
|
| 312 |
+
<article><span>SUCCESS RATE</span><strong>${Number(result.success_rate).toFixed(1)}%</strong></article>
|
| 313 |
+
<article><span>COLLISION RATE</span><strong>${Number(result.collision_rate).toFixed(1)}%</strong></article>
|
| 314 |
+
<article><span>MIN CLEARANCE</span><strong>${Number(result.minimum_clearance_m).toFixed(3)}m</strong></article>`;
|
| 315 |
+
panel.hidden = false;
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
async function runIsaacValidation() {
|
| 319 |
+
const button = document.querySelector("#run-isaac");
|
| 320 |
+
button.disabled = true;
|
| 321 |
+
button.textContent = "等待 Scene Adapter…";
|
| 322 |
+
try {
|
| 323 |
+
if (!isaacPackage) await createIsaacPackage(false);
|
| 324 |
+
isaacRun = await api("/api/isaac/runs", {
|
| 325 |
+
method: "POST",
|
| 326 |
+
body: JSON.stringify({
|
| 327 |
+
package_url: isaacPackage.download_url,
|
| 328 |
+
backend: document.querySelector("#isaac-backend").value,
|
| 329 |
+
episodes_per_variant: Number(document.querySelector("#isaac-episodes").value),
|
| 330 |
+
}),
|
| 331 |
+
});
|
| 332 |
+
renderIsaacResult(isaacRun);
|
| 333 |
+
while (!["succeeded", "failed"].includes(isaacRun.status)) {
|
| 334 |
+
await new Promise((resolve) => setTimeout(resolve, 700));
|
| 335 |
+
isaacRun = await api(`/api/isaac/runs/${isaacRun.id}`);
|
| 336 |
+
renderIsaacResult(isaacRun);
|
| 337 |
+
}
|
| 338 |
+
button.textContent = "再次執行 Policy Validation";
|
| 339 |
+
} catch (error) {
|
| 340 |
+
errorBox.textContent = `Isaac validation 無法啟動:${error.message}`;
|
| 341 |
+
errorBox.hidden = false;
|
| 342 |
+
button.textContent = "執行 Policy Validation";
|
| 343 |
+
} finally {
|
| 344 |
+
button.disabled = false;
|
| 345 |
+
}
|
| 346 |
+
}
|
| 347 |
+
|
| 348 |
+
form.addEventListener("submit", (event) => {
|
| 349 |
+
event.preventDefault();
|
| 350 |
+
const values = formValues();
|
| 351 |
+
const errors = validateScenario(values);
|
| 352 |
+
if (errors.length) {
|
| 353 |
+
errorBox.textContent = errors.map((error) => error.message).join("、");
|
| 354 |
+
errorBox.hidden = false;
|
| 355 |
+
form.elements[errors[0].field].focus();
|
| 356 |
+
return;
|
| 357 |
+
}
|
| 358 |
+
errorBox.hidden = true;
|
| 359 |
+
manifest = buildManifest(values);
|
| 360 |
+
renderQueue();
|
| 361 |
+
});
|
| 362 |
+
|
| 363 |
+
form.addEventListener("input", renderDraft);
|
| 364 |
+
document.querySelectorAll("[data-preset]").forEach((button) => button.addEventListener("click", () => setPreset(button.dataset.preset)));
|
| 365 |
+
document.querySelector("#reset").addEventListener("click", () => setPreset("warehouse"));
|
| 366 |
+
document.querySelectorAll("[data-mode]").forEach((button) => button.addEventListener("click", () => {
|
| 367 |
+
mode = button.dataset.mode;
|
| 368 |
+
document.querySelectorAll("[data-mode]").forEach((item) => item.classList.toggle("active", item === button));
|
| 369 |
+
document.querySelector("#nim-config").hidden = mode !== "nim";
|
| 370 |
+
runButton.textContent = mode === "nim" ? "送往 Cosmos NIM" : "執行批次";
|
| 371 |
+
}));
|
| 372 |
+
document.querySelector("#check-nim").addEventListener("click", checkNim);
|
| 373 |
+
document.querySelector("#evaluate-batch").addEventListener("click", runEvaluations);
|
| 374 |
+
document.querySelector("#export-isaac").addEventListener("click", () => createIsaacPackage(true));
|
| 375 |
+
document.querySelector("#run-isaac").addEventListener("click", runIsaacValidation);
|
| 376 |
+
queue.addEventListener("click", async (event) => {
|
| 377 |
+
const row = event.target.closest(".queue-item");
|
| 378 |
+
if (!row || mode !== "nim") return;
|
| 379 |
+
const index = manifest.variants.findIndex((variant) => variant.id === row.dataset.id);
|
| 380 |
+
const job = jobs[index];
|
| 381 |
+
if (job?.status === "succeeded") {
|
| 382 |
+
showVideo(job);
|
| 383 |
+
} else if (job?.status === "failed") {
|
| 384 |
+
errorBox.hidden = true;
|
| 385 |
+
try {
|
| 386 |
+
jobs[index] = await api(`/api/jobs/${job.id}/retry`, {
|
| 387 |
+
method: "POST",
|
| 388 |
+
body: JSON.stringify({ ...connectionConfig(), payload: manifest.variants[index].request }),
|
| 389 |
+
});
|
| 390 |
+
renderQueue(jobs);
|
| 391 |
+
if (!polling) pollJobs();
|
| 392 |
+
} catch (error) {
|
| 393 |
+
errorBox.textContent = `重新送出失敗:${error.message}`;
|
| 394 |
+
errorBox.hidden = false;
|
| 395 |
+
}
|
| 396 |
+
}
|
| 397 |
+
});
|
| 398 |
+
runButton.addEventListener("click", () => mode === "nim" ? runNim() : runMock());
|
| 399 |
+
downloadButton.addEventListener("click", () => {
|
| 400 |
+
const blob = new Blob([JSON.stringify(manifest, null, 2)], { type: "application/json" });
|
| 401 |
+
const link = document.createElement("a");
|
| 402 |
+
link.href = URL.createObjectURL(blob);
|
| 403 |
+
link.download = `${manifest.project.toLowerCase().replace(/\s+/g, "-")}-cosmos-manifest.json`;
|
| 404 |
+
link.click();
|
| 405 |
+
URL.revokeObjectURL(link.href);
|
| 406 |
+
});
|
| 407 |
+
document.querySelector("#copy-prompt").addEventListener("click", async (event) => {
|
| 408 |
+
await navigator.clipboard.writeText(document.querySelector("#prompt-output").textContent);
|
| 409 |
+
event.currentTarget.textContent = "已複製";
|
| 410 |
+
setTimeout(() => { event.currentTarget.textContent = "複製"; }, 1200);
|
| 411 |
+
});
|
| 412 |
+
document.querySelector("#run-preview").addEventListener("click", (event) => {
|
| 413 |
+
document.querySelector("#viewport").classList.toggle("playing");
|
| 414 |
+
event.currentTarget.textContent = document.querySelector("#viewport").classList.contains("playing") ? "Ⅱ" : "▶";
|
| 415 |
+
});
|
| 416 |
+
document.addEventListener("keydown", (event) => {
|
| 417 |
+
if ((event.metaKey || event.ctrlKey) && event.key === "Enter") form.requestSubmit();
|
| 418 |
+
});
|
| 419 |
+
|
| 420 |
+
try {
|
| 421 |
+
const draft = JSON.parse(localStorage.getItem("cosmos-scenario-draft"));
|
| 422 |
+
if (draft) fillForm(draft);
|
| 423 |
+
else setPreset("warehouse");
|
| 424 |
+
} catch {
|
| 425 |
+
setPreset("warehouse");
|
| 426 |
+
}
|
| 427 |
+
|
| 428 |
+
if (staticSpace) {
|
| 429 |
+
const nimButton = document.querySelector('[data-mode="nim"]');
|
| 430 |
+
nimButton.disabled = true;
|
| 431 |
+
nimButton.title = "Static Space 僅提供離線 Demo;NIM 需要 GitHub 版 Python proxy";
|
| 432 |
+
document.querySelector(".privacy-note").textContent = "Hosted Static Demo:NIM、Reason2 與 Isaac adapter 需從 GitHub 啟動 Python proxy 和外部 NVIDIA runtime;本頁不接收或儲存 API key。";
|
| 433 |
+
["#reason-endpoint", "#reason-api-key", "#auto-evaluate", "#evaluate-batch", "#export-isaac", "#isaac-backend", "#isaac-episodes", "#run-isaac"].forEach((selector) => {
|
| 434 |
+
document.querySelector(selector).disabled = true;
|
| 435 |
+
});
|
| 436 |
+
document.querySelector("#ranking-empty").textContent = "Static Space 展示工作流契約;Reason2 評分與 Isaac validation 請在 GitHub 版連接外部 runtime。";
|
| 437 |
+
}
|
fake_nim.py
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Tiny Cosmos NIM-compatible server for end-to-end development without a GPU."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import base64
|
| 7 |
+
import json
|
| 8 |
+
import time
|
| 9 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
ROOT = Path(__file__).resolve().parent
|
| 14 |
+
FIXTURE = ROOT / "fixtures" / "demo-world.mp4"
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class FakeNimHandler(BaseHTTPRequestHandler):
|
| 18 |
+
failures_left = 0
|
| 19 |
+
|
| 20 |
+
def json_response(self, status: int, data: dict) -> None:
|
| 21 |
+
payload = json.dumps(data).encode()
|
| 22 |
+
self.send_response(status)
|
| 23 |
+
self.send_header("Content-Type", "application/json")
|
| 24 |
+
self.send_header("Content-Length", str(len(payload)))
|
| 25 |
+
self.end_headers()
|
| 26 |
+
self.wfile.write(payload)
|
| 27 |
+
|
| 28 |
+
def do_GET(self) -> None: # noqa: N802
|
| 29 |
+
if self.path == "/v1/health/ready":
|
| 30 |
+
self.json_response(200, {"object": "fake readiness check", "message": "ready", "status": "ready"})
|
| 31 |
+
else:
|
| 32 |
+
self.json_response(404, {"error": "not found"})
|
| 33 |
+
|
| 34 |
+
def do_POST(self) -> None: # noqa: N802
|
| 35 |
+
if self.path != "/v1/infer":
|
| 36 |
+
self.json_response(404, {"error": "not found"})
|
| 37 |
+
return
|
| 38 |
+
length = int(self.headers.get("Content-Length", "0"))
|
| 39 |
+
request = json.loads(self.rfile.read(length) or b"{}")
|
| 40 |
+
if not request.get("prompt"):
|
| 41 |
+
self.json_response(422, {"error": "prompt is required"})
|
| 42 |
+
return
|
| 43 |
+
if type(self).failures_left > 0:
|
| 44 |
+
type(self).failures_left -= 1
|
| 45 |
+
self.json_response(503, {"error": "simulated temporary overload"})
|
| 46 |
+
return
|
| 47 |
+
time.sleep(0.35)
|
| 48 |
+
self.json_response(200, {"b64_video": base64.b64encode(FIXTURE.read_bytes()).decode(), "seed": request.get("seed")})
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def main() -> None:
|
| 52 |
+
parser = argparse.ArgumentParser()
|
| 53 |
+
parser.add_argument("--host", default="127.0.0.1")
|
| 54 |
+
parser.add_argument("--port", type=int, default=8000)
|
| 55 |
+
parser.add_argument("--fail-first", type=int, default=0, help="Return 503 for the first N inference calls")
|
| 56 |
+
args = parser.parse_args()
|
| 57 |
+
if not FIXTURE.exists():
|
| 58 |
+
raise SystemExit(f"Missing checked-in fixture: {FIXTURE}")
|
| 59 |
+
FakeNimHandler.failures_left = args.fail_first
|
| 60 |
+
server = ThreadingHTTPServer((args.host, args.port), FakeNimHandler)
|
| 61 |
+
print(f"Fake Cosmos NIM: http://{args.host}:{args.port}")
|
| 62 |
+
try:
|
| 63 |
+
server.serve_forever()
|
| 64 |
+
except KeyboardInterrupt:
|
| 65 |
+
pass
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
if __name__ == "__main__":
|
| 69 |
+
main()
|
fake_reason.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""OpenAI-compatible Cosmos Reason2 fixture for local end-to-end tests."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class FakeReasonHandler(BaseHTTPRequestHandler):
|
| 11 |
+
request_count = 0
|
| 12 |
+
|
| 13 |
+
def send_json(self, status: int, data: dict) -> None:
|
| 14 |
+
payload = json.dumps(data).encode()
|
| 15 |
+
self.send_response(status)
|
| 16 |
+
self.send_header("Content-Type", "application/json")
|
| 17 |
+
self.send_header("Content-Length", str(len(payload)))
|
| 18 |
+
self.end_headers()
|
| 19 |
+
self.wfile.write(payload)
|
| 20 |
+
|
| 21 |
+
def do_GET(self) -> None: # noqa: N802
|
| 22 |
+
if self.path == "/v1/health/ready":
|
| 23 |
+
self.send_json(200, {"status": "ready", "model": "nvidia/cosmos-reason2-2b"})
|
| 24 |
+
else:
|
| 25 |
+
self.send_json(404, {"error": "not found"})
|
| 26 |
+
|
| 27 |
+
def do_POST(self) -> None: # noqa: N802
|
| 28 |
+
if self.path != "/v1/chat/completions":
|
| 29 |
+
self.send_json(404, {"error": "not found"})
|
| 30 |
+
return
|
| 31 |
+
size = int(self.headers.get("Content-Length", "0"))
|
| 32 |
+
request = json.loads(self.rfile.read(size) or b"{}")
|
| 33 |
+
content = request.get("messages", [{}, {}])[-1].get("content", [])
|
| 34 |
+
has_video = any(item.get("type") == "video_url" for item in content if isinstance(item, dict))
|
| 35 |
+
if not has_video:
|
| 36 |
+
self.send_json(422, {"error": "video_url required"})
|
| 37 |
+
return
|
| 38 |
+
type(self).request_count += 1
|
| 39 |
+
offset = min(18, type(self).request_count * 4)
|
| 40 |
+
result = {
|
| 41 |
+
"collision_risk": 48 + offset,
|
| 42 |
+
"physics_plausibility": 82 - offset,
|
| 43 |
+
"task_success": 68 - offset,
|
| 44 |
+
"danger_severity": 55 + offset,
|
| 45 |
+
"observed_event": "The robot approaches a moving obstacle while a worker enters the operating corridor.",
|
| 46 |
+
"rationale": "The minimum separation appears small and the avoidance maneuver begins late, while object motion remains mostly plausible.",
|
| 47 |
+
"recommended_test": "Randomize worker entry time and measure minimum robot-to-human clearance.",
|
| 48 |
+
}
|
| 49 |
+
response = {"choices": [{"message": {"role": "assistant", "content": "```json\n" + json.dumps(result) + "\n```"}}]}
|
| 50 |
+
self.send_json(200, response)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
def main() -> None:
|
| 54 |
+
parser = argparse.ArgumentParser()
|
| 55 |
+
parser.add_argument("--host", default="127.0.0.1")
|
| 56 |
+
parser.add_argument("--port", type=int, default=8001)
|
| 57 |
+
args = parser.parse_args()
|
| 58 |
+
server = ThreadingHTTPServer((args.host, args.port), FakeReasonHandler)
|
| 59 |
+
print(f"Fake Cosmos Reason2: http://{args.host}:{args.port}")
|
| 60 |
+
try:
|
| 61 |
+
server.serve_forever()
|
| 62 |
+
except KeyboardInterrupt:
|
| 63 |
+
pass
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
if __name__ == "__main__":
|
| 67 |
+
main()
|
fixtures/demo-world.mp4
ADDED
|
Binary file (3.25 kB). View file
|
|
|
fixtures/warehouse_baseline_failure.metrics.json
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"status": "failed",
|
| 3 |
+
"episodes": 3,
|
| 4 |
+
"success_rate": 0.0,
|
| 5 |
+
"collision_rate": 33.33,
|
| 6 |
+
"minimum_clearance_m": 0.08,
|
| 7 |
+
"mean_placement_error_m": 0.07,
|
| 8 |
+
"acceptance": {
|
| 9 |
+
"minimum_success_rate": 90,
|
| 10 |
+
"maximum_collision_rate": 0,
|
| 11 |
+
"minimum_clearance_m": 0.15
|
| 12 |
+
},
|
| 13 |
+
"results": [
|
| 14 |
+
{"episode": 1, "success": false, "collision": false, "controller_done": false, "steps": 4000, "placement_error_m": 0.09, "minimum_clearance_m": 0.18, "collision_links": []},
|
| 15 |
+
{"episode": 2, "success": false, "collision": true, "controller_done": true, "steps": 1680, "placement_error_m": 0.07, "minimum_clearance_m": 0.08, "collision_links": ["/World/Franka/panda_link6"]},
|
| 16 |
+
{"episode": 3, "success": false, "collision": false, "controller_done": true, "steps": 1720, "placement_error_m": 0.05, "minimum_clearance_m": 0.17, "collision_links": []}
|
| 17 |
+
]
|
| 18 |
+
}
|
index.html
CHANGED
|
@@ -1,19 +1,178 @@
|
|
| 1 |
<!doctype html>
|
| 2 |
-
<html>
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
</html>
|
|
|
|
| 1 |
<!doctype html>
|
| 2 |
+
<html lang="zh-Hant">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 6 |
+
<meta name="description" content="NVIDIA Cosmos Physical AI 合成情境編排與 NIM 執行工具。">
|
| 7 |
+
<title>Cosmos Scenario Studio</title>
|
| 8 |
+
<link rel="stylesheet" href="styles.css?v=2">
|
| 9 |
+
</head>
|
| 10 |
+
<body>
|
| 11 |
+
<header class="topbar">
|
| 12 |
+
<a class="brand" href="#"><span class="brand-mark">C</span><span>COSMOS<br><small>SCENARIO STUDIO</small></span></a>
|
| 13 |
+
<nav aria-label="主要導覽"><a href="#compose">編排</a><a href="#world">世界預覽</a><a href="#dataset">資料集</a><a href="https://github.com/jjfishjj/cosmos-scenario-studio" target="_blank" rel="noreferrer">GitHub ↗</a></nav>
|
| 14 |
+
<div class="system-status"><i></i><span>PHYSICAL AI LAB</span></div>
|
| 15 |
+
</header>
|
| 16 |
+
|
| 17 |
+
<main>
|
| 18 |
+
<section class="hero">
|
| 19 |
+
<div>
|
| 20 |
+
<p class="eyebrow">NVIDIA COSMOS // SYNTHETIC WORLD OPS</p>
|
| 21 |
+
<h1>先生成世界,<br><em>再讓機器犯錯。</em></h1>
|
| 22 |
+
</div>
|
| 23 |
+
<p class="hero-copy">把一個 Physical AI 任務拆成可重現的世界變體,交給 Cosmos-Predict2.5 生成,找出真機測試前最值得看的失敗情境。</p>
|
| 24 |
+
</section>
|
| 25 |
+
|
| 26 |
+
<section class="release-strip" aria-label="部署與驗證邊界">
|
| 27 |
+
<article><span>PUBLIC DEMO</span><strong>Offline-first</strong><small>不需要 GPU 或 API key 即可完成主要旅程</small></article>
|
| 28 |
+
<article><span>CONTRACT TESTS</span><strong>3 JS + 10 Python</strong><small>Prompt、proxy、Reason2 與 Isaac 工作包</small></article>
|
| 29 |
+
<article><span>REAL RUNTIME</span><strong>External RTX</strong><small>Cosmos NIM 與 Isaac Sim 不在 Space 內執行</small></article>
|
| 30 |
+
<article class="boundary"><span>CLAIM BOUNDARY</span><strong>Workflow ≠ physics evidence</strong><small>Fixture 結果不代表真實影片或 PhysX 驗證</small></article>
|
| 31 |
+
</section>
|
| 32 |
+
|
| 33 |
+
<section class="workspace" id="compose">
|
| 34 |
+
<aside class="panel composer">
|
| 35 |
+
<div class="panel-head">
|
| 36 |
+
<div><p class="step">01 // SCENARIO</p><h2>情境編排</h2></div>
|
| 37 |
+
<button id="reset" class="icon-button" title="還原預設" aria-label="還原預設">↺</button>
|
| 38 |
+
</div>
|
| 39 |
+
|
| 40 |
+
<div class="presets" aria-label="情境範本">
|
| 41 |
+
<button class="active" data-preset="warehouse">倉儲</button>
|
| 42 |
+
<button data-preset="sidewalk">配送</button>
|
| 43 |
+
<button data-preset="factory">裝配</button>
|
| 44 |
+
</div>
|
| 45 |
+
|
| 46 |
+
<form id="scenario-form" novalidate>
|
| 47 |
+
<label>情境名稱<input name="name" autocomplete="off"></label>
|
| 48 |
+
<label>環境<textarea name="environment" rows="2"></textarea></label>
|
| 49 |
+
<label>機器人<textarea name="robot" rows="2"></textarea></label>
|
| 50 |
+
<label>任務<textarea name="task" rows="3"></textarea></label>
|
| 51 |
+
<label>運行條件<textarea name="condition" rows="2"></textarea></label>
|
| 52 |
+
<label>關鍵風險<textarea name="hazard" rows="2"></textarea></label>
|
| 53 |
+
<div class="form-grid">
|
| 54 |
+
<label>世界變體<input name="variants" type="number" min="1" max="12" value="4"></label>
|
| 55 |
+
<label>Seed<input name="seed" type="number" value="7"></label>
|
| 56 |
+
<label>解析度<select name="resolution"><option value="720">720p · 16:9</option><option value="480">480p · 16:9</option><option value="256">256p · 16:9</option></select></label>
|
| 57 |
+
<label>影格數<input name="frames" type="number" min="25" max="197" value="121"></label>
|
| 58 |
+
</div>
|
| 59 |
+
<div id="form-error" class="form-error" role="alert" hidden></div>
|
| 60 |
+
<button class="primary" type="submit"><span>生成實驗</span><b>⌘ ↵</b></button>
|
| 61 |
+
</form>
|
| 62 |
+
</aside>
|
| 63 |
+
|
| 64 |
+
<section class="panel world" id="world">
|
| 65 |
+
<div class="panel-head">
|
| 66 |
+
<div><p class="step">02 // WORLD PREVIEW</p><h2 id="world-title">夜間倉儲補貨</h2></div>
|
| 67 |
+
<span class="model-pill">PREDICT 2.5 · 2B</span>
|
| 68 |
+
</div>
|
| 69 |
+
|
| 70 |
+
<div class="viewport" id="viewport">
|
| 71 |
+
<div class="scanlines"></div>
|
| 72 |
+
<div class="warehouse-scene" aria-label="抽象倉儲世界預覽">
|
| 73 |
+
<div class="shelf shelf-a"></div><div class="shelf shelf-b"></div>
|
| 74 |
+
<div class="floor-line one"></div><div class="floor-line two"></div>
|
| 75 |
+
<div class="robot"><i></i><span></span></div>
|
| 76 |
+
<div class="human"><i></i><span></span></div>
|
| 77 |
+
<div class="hazard-zone"></div>
|
| 78 |
+
</div>
|
| 79 |
+
<div class="view-hud hud-top"><span>CAM_01 / EGO</span><span id="frame-count">121 FRAMES</span></div>
|
| 80 |
+
<div class="view-hud hud-bottom"><span><i></i> WORLD READY</span><span id="seed-label">SEED 7</span></div>
|
| 81 |
+
<button class="play" id="run-preview" aria-label="播放模擬預覽">▶</button>
|
| 82 |
+
</div>
|
| 83 |
+
|
| 84 |
+
<div class="risk-strip">
|
| 85 |
+
<div><span>風險覆蓋</span><strong id="risk-score">—</strong></div>
|
| 86 |
+
<div class="risk-bar"><i id="risk-bar"></i></div>
|
| 87 |
+
<p id="risk-copy">等待情境分析</p>
|
| 88 |
+
</div>
|
| 89 |
+
|
| 90 |
+
<div class="prompt-block">
|
| 91 |
+
<div class="prompt-head"><span>COSMOS PROMPT</span><button id="copy-prompt">複製</button></div>
|
| 92 |
+
<p id="prompt-output"></p>
|
| 93 |
+
</div>
|
| 94 |
+
</section>
|
| 95 |
+
|
| 96 |
+
<aside class="panel dataset" id="dataset">
|
| 97 |
+
<div class="panel-head">
|
| 98 |
+
<div><p class="step">03 // DATASET</p><h2>生成佇列</h2></div>
|
| 99 |
+
<span id="queue-count">0 WORLDS</span>
|
| 100 |
+
</div>
|
| 101 |
+
|
| 102 |
+
<div class="mode-tabs" role="tablist">
|
| 103 |
+
<button class="active" data-mode="mock">本機 Demo</button>
|
| 104 |
+
<button data-mode="nim">Cosmos NIM</button>
|
| 105 |
+
</div>
|
| 106 |
+
|
| 107 |
+
<div id="nim-config" class="nim-config" hidden>
|
| 108 |
+
<div class="nim-health"><span><i id="nim-health-dot"></i><b id="nim-health-label">尚未檢查</b></span><button id="check-nim" type="button">檢查連線</button></div>
|
| 109 |
+
<label>NIM Endpoint<input id="endpoint" value="http://127.0.0.1:8000/v1/infer"></label>
|
| 110 |
+
<label>API Key(只存於此頁)<input id="api-key" type="password" placeholder="選填"></label>
|
| 111 |
+
<div class="retry-grid">
|
| 112 |
+
<label>錯誤重試<select id="max-retries"><option value="0">不重試</option><option value="1">1 次</option><option value="2" selected>2 次</option><option value="3">3 次</option></select></label>
|
| 113 |
+
<label>Timeout<input id="timeout-seconds" type="number" min="60" max="1800" value="900"></label>
|
| 114 |
+
</div>
|
| 115 |
+
</div>
|
| 116 |
+
|
| 117 |
+
<div id="empty-queue" class="empty-queue">
|
| 118 |
+
<span>◎</span>
|
| 119 |
+
<p>世界尚未生成</p>
|
| 120 |
+
<small>完成左側情境後建立批次變體</small>
|
| 121 |
+
</div>
|
| 122 |
+
<div id="queue" class="queue" hidden></div>
|
| 123 |
+
|
| 124 |
+
<div id="result-player" class="result-player" hidden>
|
| 125 |
+
<div class="result-head"><span id="result-title">GENERATED WORLD</span><a id="open-video" href="#" target="_blank">開啟影片 ↗</a></div>
|
| 126 |
+
<video id="generated-video" controls playsinline></video>
|
| 127 |
+
</div>
|
| 128 |
+
|
| 129 |
+
<div class="dataset-actions">
|
| 130 |
+
<button id="run-batch" class="primary" disabled>執行批次</button>
|
| 131 |
+
<button id="download" class="secondary" disabled>下載 Manifest</button>
|
| 132 |
+
</div>
|
| 133 |
+
<p class="privacy-note">NIM 模式由本機 Python proxy 呼叫 endpoint;API key 只存在記憶體,不會寫入檔案或 localStorage。</p>
|
| 134 |
+
</aside>
|
| 135 |
+
</section>
|
| 136 |
+
|
| 137 |
+
<section class="analysis-lab" id="reason-lab">
|
| 138 |
+
<div class="analysis-copy">
|
| 139 |
+
<p class="step">04 // REASON & VALIDATE</p>
|
| 140 |
+
<h2>從影片找出最值得重播的失敗。</h2>
|
| 141 |
+
<p>Cosmos Reason2 觀察生成影片,評估碰撞風險、物理合理性、任務成功與危險程度;排序結果會直接決定 Isaac Sim 的壓力測試範圍。</p>
|
| 142 |
+
</div>
|
| 143 |
+
<div class="reason-panel">
|
| 144 |
+
<div class="reason-config">
|
| 145 |
+
<label>Reason2 Endpoint<input id="reason-endpoint" value="http://127.0.0.1:8001/v1/chat/completions"></label>
|
| 146 |
+
<label>Reason2 API Key<input id="reason-api-key" type="password" placeholder="選填"></label>
|
| 147 |
+
<label class="auto-evaluate"><input id="auto-evaluate" type="checkbox" checked> 生成後自動評分</label>
|
| 148 |
+
</div>
|
| 149 |
+
<div class="reason-actions">
|
| 150 |
+
<button id="evaluate-batch" class="secondary" disabled>用 Reason2 評分</button>
|
| 151 |
+
<button id="export-isaac" class="primary" disabled>下載 Isaac 工作包</button>
|
| 152 |
+
</div>
|
| 153 |
+
<div class="isaac-runner">
|
| 154 |
+
<label>Scene Adapter<select id="isaac-backend"><option value="contract">Contract worker</option><option value="isaac">Isaac Sim runtime</option></select></label>
|
| 155 |
+
<label>每個情境 Episodes<input id="isaac-episodes" type="number" min="1" max="100" value="3"></label>
|
| 156 |
+
<button id="run-isaac" class="secondary" disabled>執行 Policy Validation</button>
|
| 157 |
+
</div>
|
| 158 |
+
<div id="isaac-result" class="isaac-result" hidden></div>
|
| 159 |
+
<div id="ranking-empty" class="ranking-empty">完成 Cosmos 影片生成後,這裡會顯示 edge-case ranking。</div>
|
| 160 |
+
<div id="ranking" class="ranking" hidden></div>
|
| 161 |
+
</div>
|
| 162 |
+
</section>
|
| 163 |
+
|
| 164 |
+
<section class="architecture">
|
| 165 |
+
<p class="step">PIPELINE // FROM IDEA TO TRAINING DATA</p>
|
| 166 |
+
<div class="flow">
|
| 167 |
+
<article><b>01</b><span>Scenario brief</span><small>任務與風險</small></article>
|
| 168 |
+
<i>→</i><article><b>02</b><span>Cosmos Predict</span><small>World generation</small></article>
|
| 169 |
+
<i>→</i><article><b>03</b><span>Review & curate</span><small>失敗情境篩選</small></article>
|
| 170 |
+
<i>→</i><article><b>04</b><span>Isaac / Policy</span><small>訓練與驗證</small></article>
|
| 171 |
+
</div>
|
| 172 |
+
</section>
|
| 173 |
+
</main>
|
| 174 |
+
|
| 175 |
+
<footer><span>COSMOS SCENARIO STUDIO</span><span>Prototype · NVIDIA Cosmos-Predict2.5 compatible</span></footer>
|
| 176 |
+
<script type="module" src="app.mjs?v=4"></script>
|
| 177 |
+
</body>
|
| 178 |
</html>
|
isaac_adapter/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Isaac Sim scene adapter for Cosmos Scenario Studio work packages."""
|
| 2 |
+
|
isaac_adapter/build_warehouse_scene.py
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build the reference Warehouse Franka USD with Isaac Sim 5.1.
|
| 2 |
+
|
| 3 |
+
Run with Isaac Sim's bundled Python:
|
| 4 |
+
./python.sh -m isaac_adapter.build_warehouse_scene --output artifacts/warehouse_franka.usd
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
import argparse
|
| 10 |
+
import json
|
| 11 |
+
from pathlib import Path
|
| 12 |
+
|
| 13 |
+
from isaacsim import SimulationApp
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def main() -> None:
|
| 17 |
+
parser = argparse.ArgumentParser(description="Build the Cosmos Warehouse Franka reference scene")
|
| 18 |
+
parser.add_argument("--output", default="artifacts/warehouse_franka.usd")
|
| 19 |
+
parser.add_argument("--mapping", default="artifacts/warehouse_franka.mapping.json")
|
| 20 |
+
parser.add_argument("--headless", action="store_true")
|
| 21 |
+
args = parser.parse_args()
|
| 22 |
+
|
| 23 |
+
output = Path(args.output).expanduser().resolve()
|
| 24 |
+
mapping_path = Path(args.mapping).expanduser().resolve()
|
| 25 |
+
output.parent.mkdir(parents=True, exist_ok=True)
|
| 26 |
+
mapping_path.parent.mkdir(parents=True, exist_ok=True)
|
| 27 |
+
|
| 28 |
+
simulation_app = SimulationApp({"headless": args.headless})
|
| 29 |
+
try:
|
| 30 |
+
import numpy as np
|
| 31 |
+
import omni.usd
|
| 32 |
+
from isaacsim.core.api import World
|
| 33 |
+
from isaacsim.core.api.objects import DynamicCuboid, FixedCuboid, VisualCuboid
|
| 34 |
+
from isaacsim.robot.manipulators.examples.franka import Franka
|
| 35 |
+
from isaacsim.sensors.camera import Camera
|
| 36 |
+
from pxr import Gf, Sdf, UsdGeom, UsdLux
|
| 37 |
+
|
| 38 |
+
world = World(stage_units_in_meters=1.0)
|
| 39 |
+
world.scene.add_default_ground_plane()
|
| 40 |
+
world.scene.add(Franka(prim_path="/World/Franka", name="warehouse_franka"))
|
| 41 |
+
world.scene.add(
|
| 42 |
+
DynamicCuboid(
|
| 43 |
+
prim_path="/World/TaskObject",
|
| 44 |
+
name="task_object",
|
| 45 |
+
position=np.array([0.42, 0.24, 0.035]),
|
| 46 |
+
scale=np.array([0.055, 0.055, 0.055]),
|
| 47 |
+
size=1.0,
|
| 48 |
+
color=np.array([0.05, 0.3, 0.95]),
|
| 49 |
+
mass=0.05,
|
| 50 |
+
)
|
| 51 |
+
)
|
| 52 |
+
world.scene.add(
|
| 53 |
+
VisualCuboid(
|
| 54 |
+
prim_path="/World/TaskTarget",
|
| 55 |
+
name="task_target",
|
| 56 |
+
position=np.array([0.42, -0.24, 0.028]),
|
| 57 |
+
scale=np.array([0.075, 0.075, 0.008]),
|
| 58 |
+
size=1.0,
|
| 59 |
+
color=np.array([0.1, 0.9, 0.25]),
|
| 60 |
+
)
|
| 61 |
+
)
|
| 62 |
+
world.scene.add(
|
| 63 |
+
FixedCuboid(
|
| 64 |
+
prim_path="/World/Hazard",
|
| 65 |
+
name="hazard",
|
| 66 |
+
position=np.array([0.62, 0.0, 0.18]),
|
| 67 |
+
scale=np.array([0.10, 0.10, 0.36]),
|
| 68 |
+
size=1.0,
|
| 69 |
+
color=np.array([0.95, 0.15, 0.05]),
|
| 70 |
+
)
|
| 71 |
+
)
|
| 72 |
+
|
| 73 |
+
# A compact warehouse cell: two shelf banks and a rear safety wall.
|
| 74 |
+
shelf_color = np.array([0.22, 0.25, 0.29])
|
| 75 |
+
for side, y in (("Left", 1.05), ("Right", -1.05)):
|
| 76 |
+
for level, z in enumerate((0.18, 0.72, 1.26, 1.80)):
|
| 77 |
+
world.scene.add(
|
| 78 |
+
FixedCuboid(
|
| 79 |
+
prim_path=f"/World/Warehouse/Shelf{side}/Deck{level}",
|
| 80 |
+
name=f"shelf_{side.lower()}_{level}",
|
| 81 |
+
position=np.array([0.55, y, z]),
|
| 82 |
+
scale=np.array([1.9, 0.48, 0.06]),
|
| 83 |
+
size=1.0,
|
| 84 |
+
color=shelf_color,
|
| 85 |
+
)
|
| 86 |
+
)
|
| 87 |
+
world.scene.add(
|
| 88 |
+
FixedCuboid(
|
| 89 |
+
prim_path="/World/Warehouse/RearWall",
|
| 90 |
+
name="rear_wall",
|
| 91 |
+
position=np.array([1.48, 0.0, 1.05]),
|
| 92 |
+
scale=np.array([0.08, 2.6, 2.1]),
|
| 93 |
+
size=1.0,
|
| 94 |
+
color=np.array([0.14, 0.16, 0.18]),
|
| 95 |
+
)
|
| 96 |
+
)
|
| 97 |
+
|
| 98 |
+
Camera(
|
| 99 |
+
prim_path="/World/Camera",
|
| 100 |
+
position=np.array([2.55, 2.2, 1.75]),
|
| 101 |
+
orientation=np.array([0.3265, 0.233, 0.562, 0.715]),
|
| 102 |
+
frequency=30,
|
| 103 |
+
resolution=(1280, 720),
|
| 104 |
+
)
|
| 105 |
+
stage = omni.usd.get_context().get_stage()
|
| 106 |
+
light = UsdLux.DistantLight.Define(stage, Sdf.Path("/World/KeyLight"))
|
| 107 |
+
light.CreateIntensityAttr(850.0)
|
| 108 |
+
light.AddRotateXYZOp().Set(Gf.Vec3f(35.0, -25.0, 20.0))
|
| 109 |
+
dome = UsdLux.DomeLight.Define(stage, Sdf.Path("/World/FillLight"))
|
| 110 |
+
dome.CreateIntensityAttr(250.0)
|
| 111 |
+
|
| 112 |
+
warehouse = stage.GetPrimAtPath("/World/Warehouse")
|
| 113 |
+
if warehouse:
|
| 114 |
+
UsdGeom.Imageable(warehouse).MakeVisible()
|
| 115 |
+
world.reset()
|
| 116 |
+
for _ in range(10):
|
| 117 |
+
world.step(render=not args.headless)
|
| 118 |
+
stage.GetRootLayer().Export(str(output))
|
| 119 |
+
|
| 120 |
+
collision_parents = [f"/World/Franka/panda_link{i}" for i in range(8)] + ["/World/Franka/panda_hand"]
|
| 121 |
+
mapping = {
|
| 122 |
+
"schema_version": "1.0",
|
| 123 |
+
"scene_usd": str(output),
|
| 124 |
+
"policy": "franka_pick_place",
|
| 125 |
+
"prims": {
|
| 126 |
+
"robot": "/World/Franka",
|
| 127 |
+
"collision_parent": "/World/Franka/panda_link0",
|
| 128 |
+
"collision_parents": collision_parents,
|
| 129 |
+
"task_object": "/World/TaskObject",
|
| 130 |
+
"task_target": "/World/TaskTarget",
|
| 131 |
+
"hazard": "/World/Hazard",
|
| 132 |
+
"camera": "/World/Camera",
|
| 133 |
+
"lights": ["/World/KeyLight", "/World/FillLight"],
|
| 134 |
+
},
|
| 135 |
+
"clearance_radii_m": {"robot": 0.07, "hazard": 0.08},
|
| 136 |
+
"success_tolerance_m": 0.045,
|
| 137 |
+
"end_effector_offset": [0.0, 0.0, 0.10],
|
| 138 |
+
"max_steps": 4000,
|
| 139 |
+
}
|
| 140 |
+
mapping_path.write_text(json.dumps(mapping, indent=2) + "\n")
|
| 141 |
+
print(json.dumps({"scene_usd": str(output), "mapping": str(mapping_path)}))
|
| 142 |
+
finally:
|
| 143 |
+
simulation_app.close()
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
if __name__ == "__main__":
|
| 147 |
+
main()
|
isaac_adapter/calibration.py
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Diagnose a real Isaac Sim baseline without weakening safety thresholds."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import json
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def build_calibration_report(metrics: dict[str, Any], mapping: dict[str, Any]) -> dict[str, Any]:
|
| 12 |
+
results = metrics.get("results", [])
|
| 13 |
+
if not isinstance(results, list) or not results:
|
| 14 |
+
raise ValueError("metrics must contain at least one episode result")
|
| 15 |
+
acceptance = metrics.get("acceptance", {})
|
| 16 |
+
tolerance = float(mapping.get("success_tolerance_m", 0.045))
|
| 17 |
+
timed_out = [item for item in results if not item.get("controller_done", False)]
|
| 18 |
+
collisions = [item for item in results if item.get("collision", False)]
|
| 19 |
+
placement_only = [
|
| 20 |
+
item for item in results
|
| 21 |
+
if item.get("controller_done", False)
|
| 22 |
+
and not item.get("collision", False)
|
| 23 |
+
and float(item.get("placement_error_m", 0)) > tolerance
|
| 24 |
+
]
|
| 25 |
+
low_clearance = [
|
| 26 |
+
item for item in results
|
| 27 |
+
if float(item.get("minimum_clearance_m", 0)) < float(acceptance.get("minimum_clearance_m", 0))
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
findings: list[dict[str, Any]] = []
|
| 31 |
+
candidates: list[dict[str, Any]] = []
|
| 32 |
+
if timed_out:
|
| 33 |
+
findings.append({
|
| 34 |
+
"code": "controller_timeout",
|
| 35 |
+
"episodes": [item.get("episode") for item in timed_out],
|
| 36 |
+
"diagnosis": "PickPlaceController did not finish within max_steps.",
|
| 37 |
+
"action": "Inspect reachability and try calibrated end-effector Z offsets; do not relax success criteria.",
|
| 38 |
+
})
|
| 39 |
+
current = list(mapping.get("end_effector_offset", [0.0, 0.0, 0.10]))
|
| 40 |
+
for z in (0.06, 0.10, 0.14):
|
| 41 |
+
candidate = current.copy()
|
| 42 |
+
candidate[2] = z
|
| 43 |
+
candidates.append({"name": f"offset-z-{z:.2f}", "patch": {"end_effector_offset": candidate}})
|
| 44 |
+
if placement_only:
|
| 45 |
+
findings.append({
|
| 46 |
+
"code": "placement_error",
|
| 47 |
+
"episodes": [item.get("episode") for item in placement_only],
|
| 48 |
+
"diagnosis": "Controller completed, but the object remained outside the target tolerance.",
|
| 49 |
+
"action": "Inspect gripper release height and object/target origins before changing tolerance.",
|
| 50 |
+
})
|
| 51 |
+
if collisions:
|
| 52 |
+
links = sorted({link for item in collisions for link in item.get("collision_links", [])})
|
| 53 |
+
findings.append({
|
| 54 |
+
"code": "collision",
|
| 55 |
+
"episodes": [item.get("episode") for item in collisions],
|
| 56 |
+
"links": links,
|
| 57 |
+
"diagnosis": "One or more Franka links generated contact.",
|
| 58 |
+
"action": "Keep full-chain sensors enabled; inspect the named links and adjust scene geometry or trajectory.",
|
| 59 |
+
})
|
| 60 |
+
if low_clearance:
|
| 61 |
+
findings.append({
|
| 62 |
+
"code": "unsafe_clearance",
|
| 63 |
+
"episodes": [item.get("episode") for item in low_clearance],
|
| 64 |
+
"diagnosis": "Measured hazard clearance is below the policy threshold.",
|
| 65 |
+
"action": "Treat as a genuine safety failure; move the hazard or replan the trajectory, not the threshold.",
|
| 66 |
+
})
|
| 67 |
+
if not findings and metrics.get("status") == "passed":
|
| 68 |
+
findings.append({"code": "baseline_passed", "diagnosis": "Baseline meets all acceptance gates.", "action": "Increase domain-randomization coverage before policy regression."})
|
| 69 |
+
return {
|
| 70 |
+
"source_status": metrics.get("status"),
|
| 71 |
+
"summary": {
|
| 72 |
+
"episodes": len(results),
|
| 73 |
+
"success_rate": metrics.get("success_rate"),
|
| 74 |
+
"collision_rate": metrics.get("collision_rate"),
|
| 75 |
+
"minimum_clearance_m": metrics.get("minimum_clearance_m"),
|
| 76 |
+
"controller_timeouts": len(timed_out),
|
| 77 |
+
},
|
| 78 |
+
"safety_policy_changed": False,
|
| 79 |
+
"findings": findings,
|
| 80 |
+
"mapping_candidates": candidates,
|
| 81 |
+
}
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
def main() -> None:
|
| 85 |
+
parser = argparse.ArgumentParser(description="Analyze Isaac Warehouse baseline metrics")
|
| 86 |
+
parser.add_argument("--metrics", required=True)
|
| 87 |
+
parser.add_argument("--mapping", required=True)
|
| 88 |
+
parser.add_argument("--output")
|
| 89 |
+
args = parser.parse_args()
|
| 90 |
+
metrics = json.loads(Path(args.metrics).read_text())
|
| 91 |
+
mapping = json.loads(Path(args.mapping).read_text())
|
| 92 |
+
report = build_calibration_report(metrics, mapping)
|
| 93 |
+
rendered = json.dumps(report, indent=2)
|
| 94 |
+
if args.output:
|
| 95 |
+
Path(args.output).write_text(rendered + "\n")
|
| 96 |
+
print(rendered)
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
if __name__ == "__main__":
|
| 100 |
+
main()
|
isaac_adapter/client.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
from urllib.request import Request, urlopen
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class StudioClient:
|
| 8 |
+
def __init__(self, server: str):
|
| 9 |
+
self.server = server.rstrip("/")
|
| 10 |
+
|
| 11 |
+
def request(self, path: str, body: dict | None = None, timeout: int = 10) -> dict:
|
| 12 |
+
data = None if body is None else json.dumps(body).encode()
|
| 13 |
+
headers = {"Accept": "application/json"}
|
| 14 |
+
if data is not None:
|
| 15 |
+
headers["Content-Type"] = "application/json"
|
| 16 |
+
request = Request(self.server + path, data=data, headers=headers, method="POST" if data is not None else "GET")
|
| 17 |
+
with urlopen(request, timeout=timeout) as response:
|
| 18 |
+
return json.loads(response.read())
|
| 19 |
+
|
| 20 |
+
def command(self) -> dict:
|
| 21 |
+
return self.request("/api/isaac/command")
|
| 22 |
+
|
| 23 |
+
def telemetry(self, run_id: str, body: dict) -> dict:
|
| 24 |
+
return self.request(f"/api/isaac/runs/{run_id}/telemetry", body)
|
| 25 |
+
|
| 26 |
+
def result(self, run_id: str, body: dict) -> dict:
|
| 27 |
+
return self.request(f"/api/isaac/runs/{run_id}/result", body)
|
| 28 |
+
|
isaac_adapter/contracts.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import random
|
| 5 |
+
import tempfile
|
| 6 |
+
import zipfile
|
| 7 |
+
from dataclasses import dataclass
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
REQUIRED_FILES = {"cosmos_manifest.json", "domain_randomization.json", "policy_validation.json", "scene_mapping.example.json"}
|
| 13 |
+
REQUIRED_PRIMS = {"robot", "task_object", "task_target", "hazard", "camera"}
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@dataclass(frozen=True)
|
| 17 |
+
class EpisodeResult:
|
| 18 |
+
episode: int
|
| 19 |
+
variant_id: str
|
| 20 |
+
success: bool
|
| 21 |
+
collision: bool
|
| 22 |
+
minimum_clearance_m: float
|
| 23 |
+
placement_error_m: float
|
| 24 |
+
duration_s: float
|
| 25 |
+
seed: int
|
| 26 |
+
controller_done: bool = False
|
| 27 |
+
steps: int = 0
|
| 28 |
+
collision_links: tuple[str, ...] = ()
|
| 29 |
+
|
| 30 |
+
|
| 31 |
+
def load_work_package(path: str | Path) -> dict[str, Any]:
|
| 32 |
+
archive = Path(path)
|
| 33 |
+
if not archive.is_file() or not zipfile.is_zipfile(archive):
|
| 34 |
+
raise ValueError("work package must be a ZIP file")
|
| 35 |
+
with zipfile.ZipFile(archive) as bundle:
|
| 36 |
+
names = set(bundle.namelist())
|
| 37 |
+
missing = REQUIRED_FILES - names
|
| 38 |
+
if missing:
|
| 39 |
+
raise ValueError(f"work package missing: {', '.join(sorted(missing))}")
|
| 40 |
+
result = {Path(name).stem: json.loads(bundle.read(name)) for name in REQUIRED_FILES}
|
| 41 |
+
validate_mapping(result["scene_mapping.example"])
|
| 42 |
+
return result
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def validate_mapping(mapping: dict[str, Any]) -> None:
|
| 46 |
+
prims = mapping.get("prims")
|
| 47 |
+
if not isinstance(prims, dict):
|
| 48 |
+
raise ValueError("scene mapping requires a prims object")
|
| 49 |
+
missing = REQUIRED_PRIMS - set(prims)
|
| 50 |
+
if missing:
|
| 51 |
+
raise ValueError(f"scene mapping missing prims: {', '.join(sorted(missing))}")
|
| 52 |
+
for name in REQUIRED_PRIMS:
|
| 53 |
+
if not str(prims[name]).startswith("/World/"):
|
| 54 |
+
raise ValueError(f"prim {name} must use an absolute /World path")
|
| 55 |
+
radii = mapping.get("clearance_radii_m", {})
|
| 56 |
+
if any(float(value) < 0 for value in radii.values()):
|
| 57 |
+
raise ValueError("clearance radii cannot be negative")
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
def sample_episode(randomization: dict[str, Any], seed: int) -> dict[str, Any]:
|
| 61 |
+
rng = random.Random(seed)
|
| 62 |
+
|
| 63 |
+
def uniform(path: tuple[str, str]) -> float:
|
| 64 |
+
low, high = randomization[path[0]][path[1]]
|
| 65 |
+
return round(rng.uniform(float(low), float(high)), 5)
|
| 66 |
+
|
| 67 |
+
return {
|
| 68 |
+
"seed": seed,
|
| 69 |
+
"lighting_lux": uniform(("lighting", "intensity_lux")),
|
| 70 |
+
"color_temperature_k": uniform(("lighting", "color_temperature_k")),
|
| 71 |
+
"camera_translation_m": [uniform(("camera", "translation_noise_m")) for _ in range(3)],
|
| 72 |
+
"camera_rotation_deg": [uniform(("camera", "rotation_noise_deg")) for _ in range(3)],
|
| 73 |
+
"object_xy_m": [uniform(("object", "xy_position_noise_m")) for _ in range(2)],
|
| 74 |
+
"object_yaw_deg": uniform(("object", "yaw_noise_deg")),
|
| 75 |
+
"friction": uniform(("physics", "friction")),
|
| 76 |
+
"payload_mass_kg": uniform(("physics", "payload_mass_kg")),
|
| 77 |
+
"joint_damping_scale": uniform(("physics", "joint_damping_scale")),
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def aggregate_results(results: list[EpisodeResult], acceptance: dict[str, Any]) -> dict[str, Any]:
|
| 82 |
+
if not results:
|
| 83 |
+
raise ValueError("at least one episode result is required")
|
| 84 |
+
total = len(results)
|
| 85 |
+
success_rate = round(sum(item.success for item in results) / total * 100, 2)
|
| 86 |
+
collision_rate = round(sum(item.collision for item in results) / total * 100, 2)
|
| 87 |
+
minimum_clearance = round(min(item.minimum_clearance_m for item in results), 4)
|
| 88 |
+
mean_placement_error = round(sum(item.placement_error_m for item in results) / total, 4)
|
| 89 |
+
passed = (
|
| 90 |
+
success_rate >= float(acceptance["minimum_success_rate"])
|
| 91 |
+
and collision_rate <= float(acceptance["maximum_collision_rate"])
|
| 92 |
+
and minimum_clearance >= float(acceptance["minimum_clearance_m"])
|
| 93 |
+
)
|
| 94 |
+
return {
|
| 95 |
+
"status": "passed" if passed else "failed",
|
| 96 |
+
"episodes": total,
|
| 97 |
+
"success_rate": success_rate,
|
| 98 |
+
"collision_rate": collision_rate,
|
| 99 |
+
"minimum_clearance_m": minimum_clearance,
|
| 100 |
+
"mean_placement_error_m": mean_placement_error,
|
| 101 |
+
"failure_breakdown": {
|
| 102 |
+
"controller_timeout": sum(not item.controller_done for item in results),
|
| 103 |
+
"collision": sum(item.collision for item in results),
|
| 104 |
+
"placement_error": sum(
|
| 105 |
+
item.controller_done and not item.collision and not item.success for item in results
|
| 106 |
+
),
|
| 107 |
+
},
|
| 108 |
+
"acceptance": acceptance,
|
| 109 |
+
"results": [
|
| 110 |
+
{**item.__dict__, "collision_links": list(item.collision_links)} for item in results
|
| 111 |
+
],
|
| 112 |
+
}
|
isaac_adapter/fake_runtime.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Deterministic Scene Adapter contract worker; no Isaac installation required."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import time
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
from .client import StudioClient
|
| 10 |
+
from .contracts import EpisodeResult, aggregate_results, load_work_package, sample_episode
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def run_command(client: StudioClient, command: dict) -> dict:
|
| 14 |
+
run_id = command["run_id"]
|
| 15 |
+
package = load_work_package(Path(command["package_path"]))
|
| 16 |
+
validation = package["policy_validation"]
|
| 17 |
+
randomization = package["domain_randomization"]
|
| 18 |
+
results: list[EpisodeResult] = []
|
| 19 |
+
episode_number = 0
|
| 20 |
+
for edge in validation["ranked_edge_cases"]:
|
| 21 |
+
for repeat in range(int(command.get("episodes_per_variant", 3))):
|
| 22 |
+
episode_number += 1
|
| 23 |
+
seed = int(edge["seed"]) + repeat * 101
|
| 24 |
+
sample = sample_episode(randomization, seed)
|
| 25 |
+
priority = float(edge.get("priority") or 50)
|
| 26 |
+
collision = priority >= 80 and repeat == 0
|
| 27 |
+
clearance = round(max(0.03, 0.34 - priority * 0.0024 + sample["friction"] * 0.02), 4)
|
| 28 |
+
placement_error = round(0.012 + priority * 0.00025 + abs(sample["object_xy_m"][0]) * 0.2, 4)
|
| 29 |
+
success = not collision and placement_error <= 0.035
|
| 30 |
+
client.telemetry(run_id, {"episode": episode_number, "variant_id": edge["variant_id"], "phase": "complete", "success": success, "collision": collision, "minimum_clearance_m": clearance})
|
| 31 |
+
results.append(EpisodeResult(episode_number, edge["variant_id"], success, collision, clearance, placement_error, 0.05, seed))
|
| 32 |
+
summary = aggregate_results(results, validation["acceptance"])
|
| 33 |
+
client.result(run_id, summary)
|
| 34 |
+
return summary
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def main() -> None:
|
| 38 |
+
parser = argparse.ArgumentParser()
|
| 39 |
+
parser.add_argument("--server", default="http://127.0.0.1:8090")
|
| 40 |
+
parser.add_argument("--once", action="store_true")
|
| 41 |
+
args = parser.parse_args()
|
| 42 |
+
client = StudioClient(args.server)
|
| 43 |
+
while True:
|
| 44 |
+
command = client.command()
|
| 45 |
+
if command.get("type") == "run":
|
| 46 |
+
run_command(client, command)
|
| 47 |
+
if args.once:
|
| 48 |
+
return
|
| 49 |
+
time.sleep(0.3)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
if __name__ == "__main__":
|
| 53 |
+
main()
|
isaac_adapter/runtime.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Isaac Sim 5.1 Scene Adapter for Cosmos work packages.
|
| 2 |
+
|
| 3 |
+
Run with Isaac Sim's bundled Python, not the system interpreter:
|
| 4 |
+
./python.sh -m isaac_adapter.runtime --server http://127.0.0.1:8090 --once --headless
|
| 5 |
+
|
| 6 |
+
The stable work-package/client code stays outside Isaac imports. This module
|
| 7 |
+
imports Isaac only after SimulationApp is created.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import math
|
| 15 |
+
import time
|
| 16 |
+
from pathlib import Path
|
| 17 |
+
|
| 18 |
+
from .client import StudioClient
|
| 19 |
+
from .contracts import EpisodeResult, aggregate_results, load_work_package, sample_episode, validate_mapping
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def run_isaac_command(command: dict, mapping_override: str | None, headless: bool) -> dict:
|
| 23 |
+
from isaacsim import SimulationApp
|
| 24 |
+
|
| 25 |
+
simulation_app = SimulationApp({"headless": headless})
|
| 26 |
+
try:
|
| 27 |
+
import numpy as np
|
| 28 |
+
import omni.replicator.core as rep
|
| 29 |
+
from isaacsim.core.api import World
|
| 30 |
+
from isaacsim.core.prims import SingleXFormPrim
|
| 31 |
+
from isaacsim.core.utils.stage import open_stage
|
| 32 |
+
from isaacsim.robot.manipulators.examples.franka import Franka
|
| 33 |
+
from isaacsim.robot.manipulators.examples.franka.controllers import PickPlaceController
|
| 34 |
+
from isaacsim.sensors.physics import ContactSensor
|
| 35 |
+
from pxr import Gf, UsdPhysics
|
| 36 |
+
|
| 37 |
+
package = load_work_package(command["package_path"])
|
| 38 |
+
mapping = package["scene_mapping.example"]
|
| 39 |
+
if mapping_override:
|
| 40 |
+
mapping = json.loads(Path(mapping_override).read_text())
|
| 41 |
+
validate_mapping(mapping)
|
| 42 |
+
scene_usd = Path(mapping["scene_usd"])
|
| 43 |
+
if not scene_usd.is_file():
|
| 44 |
+
raise ValueError(f"scene_usd not found: {scene_usd}")
|
| 45 |
+
open_stage(str(scene_usd))
|
| 46 |
+
world = World(stage_units_in_meters=1.0)
|
| 47 |
+
prims = mapping["prims"]
|
| 48 |
+
robot = world.scene.add(Franka(prim_path=prims["robot"], name="cosmos_robot"))
|
| 49 |
+
task_object = world.scene.add(SingleXFormPrim(prim_path=prims["task_object"], name="cosmos_task_object"))
|
| 50 |
+
task_target = world.scene.add(SingleXFormPrim(prim_path=prims["task_target"], name="cosmos_task_target"))
|
| 51 |
+
hazard = world.scene.add(SingleXFormPrim(prim_path=prims["hazard"], name="cosmos_hazard"))
|
| 52 |
+
camera = world.scene.add(SingleXFormPrim(prim_path=prims["camera"], name="cosmos_camera"))
|
| 53 |
+
collision_parents = prims.get("collision_parents", [prims.get("collision_parent", prims["robot"])])
|
| 54 |
+
contacts = []
|
| 55 |
+
for index, collision_parent in enumerate(collision_parents):
|
| 56 |
+
contacts.append(
|
| 57 |
+
world.scene.add(
|
| 58 |
+
ContactSensor(
|
| 59 |
+
prim_path=collision_parent + "/CosmosContactSensor",
|
| 60 |
+
name=f"cosmos_contact_{index}",
|
| 61 |
+
frequency=60,
|
| 62 |
+
min_threshold=0.1,
|
| 63 |
+
max_threshold=1e7,
|
| 64 |
+
radius=-1,
|
| 65 |
+
)
|
| 66 |
+
)
|
| 67 |
+
)
|
| 68 |
+
world.reset()
|
| 69 |
+
|
| 70 |
+
controller = PickPlaceController(name="cosmos_pick_place", gripper=robot.gripper, robot_articulation=robot)
|
| 71 |
+
randomization = package["domain_randomization"]
|
| 72 |
+
validation = package["policy_validation"]
|
| 73 |
+
camera_rep = rep.get.prims(path_pattern=prims["camera"])
|
| 74 |
+
light_paths = prims.get("lights", [])
|
| 75 |
+
light_rep = rep.get.prims(path_pattern="|".join(light_paths)) if light_paths else None
|
| 76 |
+
results: list[EpisodeResult] = []
|
| 77 |
+
episode_number = 0
|
| 78 |
+
radii = mapping.get("clearance_radii_m", {"robot": 0.32, "hazard": 0.30})
|
| 79 |
+
|
| 80 |
+
for edge in validation["ranked_edge_cases"]:
|
| 81 |
+
for repeat in range(int(command.get("episodes_per_variant", 3))):
|
| 82 |
+
episode_number += 1
|
| 83 |
+
seed = int(edge["seed"]) + repeat * 101
|
| 84 |
+
values = sample_episode(randomization, seed)
|
| 85 |
+
world.reset()
|
| 86 |
+
controller.reset()
|
| 87 |
+
rep.set_global_seed(seed)
|
| 88 |
+
with camera_rep:
|
| 89 |
+
delta = values["camera_translation_m"]
|
| 90 |
+
rotation = values["camera_rotation_deg"]
|
| 91 |
+
rep.modify.pose(position=rep.distribution.uniform(tuple(delta), tuple(delta)), rotation=rep.distribution.uniform(tuple(rotation), tuple(rotation)))
|
| 92 |
+
if light_rep is not None:
|
| 93 |
+
with light_rep:
|
| 94 |
+
rep.modify.attribute("intensity", rep.distribution.uniform(values["lighting_lux"], values["lighting_lux"]))
|
| 95 |
+
rep.orchestrator.step()
|
| 96 |
+
|
| 97 |
+
object_position, object_orientation = task_object.get_world_pose()
|
| 98 |
+
randomized_position = object_position.copy()
|
| 99 |
+
randomized_position[:2] += np.asarray(values["object_xy_m"])
|
| 100 |
+
yaw = math.radians(values["object_yaw_deg"])
|
| 101 |
+
yaw_quat = np.array([math.cos(yaw / 2), 0, 0, math.sin(yaw / 2)])
|
| 102 |
+
task_object.set_world_pose(position=randomized_position, orientation=yaw_quat)
|
| 103 |
+
physics_material = UsdPhysics.MaterialAPI.Get(world.stage, prims["task_object"])
|
| 104 |
+
if physics_material:
|
| 105 |
+
physics_material.CreateDynamicFrictionAttr(values["friction"])
|
| 106 |
+
|
| 107 |
+
collision = False
|
| 108 |
+
collision_links: set[str] = set()
|
| 109 |
+
minimum_clearance = float("inf")
|
| 110 |
+
started = time.perf_counter()
|
| 111 |
+
target_position, _ = task_target.get_world_pose()
|
| 112 |
+
steps = 0
|
| 113 |
+
for _step in range(int(mapping.get("max_steps", 4000))):
|
| 114 |
+
steps = _step + 1
|
| 115 |
+
world.step(render=not headless)
|
| 116 |
+
current_object, _ = task_object.get_world_pose()
|
| 117 |
+
action = controller.forward(
|
| 118 |
+
picking_position=current_object,
|
| 119 |
+
placing_position=target_position,
|
| 120 |
+
current_joint_positions=robot.get_joint_positions(),
|
| 121 |
+
end_effector_offset=np.array(mapping.get("end_effector_offset", [0, 0, 0.20])),
|
| 122 |
+
)
|
| 123 |
+
robot.apply_action(action)
|
| 124 |
+
for sensor_index, sensor in enumerate(contacts):
|
| 125 |
+
if bool(sensor.get_current_frame().get("in_contact", False)):
|
| 126 |
+
collision = True
|
| 127 |
+
collision_links.add(collision_parents[sensor_index])
|
| 128 |
+
robot_position, _ = robot.end_effector.get_world_pose()
|
| 129 |
+
hazard_position, _ = hazard.get_world_pose()
|
| 130 |
+
clearance = float(np.linalg.norm(robot_position - hazard_position)) - float(radii["robot"]) - float(radii["hazard"])
|
| 131 |
+
minimum_clearance = min(minimum_clearance, max(0.0, clearance))
|
| 132 |
+
if controller.is_done():
|
| 133 |
+
break
|
| 134 |
+
actual_position, _ = task_object.get_world_pose()
|
| 135 |
+
placement_error = float(np.linalg.norm(actual_position - target_position))
|
| 136 |
+
controller_done = controller.is_done()
|
| 137 |
+
success = controller_done and placement_error <= float(mapping.get("success_tolerance_m", 0.035)) and not collision
|
| 138 |
+
results.append(EpisodeResult(episode_number, edge["variant_id"], success, collision, round(minimum_clearance, 4), round(placement_error, 4), round(time.perf_counter() - started, 3), seed, controller_done, steps, tuple(sorted(collision_links))))
|
| 139 |
+
return aggregate_results(results, validation["acceptance"])
|
| 140 |
+
finally:
|
| 141 |
+
simulation_app.close()
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def main() -> None:
|
| 145 |
+
parser = argparse.ArgumentParser(description="Cosmos work-package Isaac Sim Scene Adapter")
|
| 146 |
+
parser.add_argument("--server", default="http://127.0.0.1:8090")
|
| 147 |
+
parser.add_argument("--mapping", help="Scene-specific JSON mapping; overrides the example mapping in the ZIP")
|
| 148 |
+
parser.add_argument("--headless", action="store_true")
|
| 149 |
+
parser.add_argument("--once", action="store_true")
|
| 150 |
+
parser.add_argument("--package", help="Run one work-package ZIP directly without the Studio queue")
|
| 151 |
+
parser.add_argument("--episodes", type=int, default=3, help="Episodes per ranked variant in direct mode")
|
| 152 |
+
parser.add_argument("--output", help="Write direct-mode metrics JSON to this path")
|
| 153 |
+
args = parser.parse_args()
|
| 154 |
+
if args.package:
|
| 155 |
+
result = run_isaac_command(
|
| 156 |
+
{"package_path": str(Path(args.package).expanduser().resolve()), "episodes_per_variant": max(1, min(100, args.episodes))},
|
| 157 |
+
args.mapping,
|
| 158 |
+
args.headless,
|
| 159 |
+
)
|
| 160 |
+
rendered = json.dumps(result, indent=2)
|
| 161 |
+
if args.output:
|
| 162 |
+
Path(args.output).expanduser().resolve().write_text(rendered + "\n")
|
| 163 |
+
print(rendered)
|
| 164 |
+
return
|
| 165 |
+
client = StudioClient(args.server)
|
| 166 |
+
while True:
|
| 167 |
+
command = client.command()
|
| 168 |
+
if command.get("type") == "run":
|
| 169 |
+
try:
|
| 170 |
+
result = run_isaac_command(command, args.mapping, args.headless)
|
| 171 |
+
client.result(command["run_id"], result)
|
| 172 |
+
except Exception as exc:
|
| 173 |
+
client.result(command["run_id"], {"status": "error", "success_rate": 0, "collision_rate": 100, "minimum_clearance_m": 0, "error": str(exc)})
|
| 174 |
+
if args.once:
|
| 175 |
+
return
|
| 176 |
+
time.sleep(0.5)
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
if __name__ == "__main__":
|
| 180 |
+
main()
|
model.mjs
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export const PRESETS = {
|
| 2 |
+
warehouse: {
|
| 3 |
+
name: "夜間倉儲補貨",
|
| 4 |
+
environment: "大型物流倉庫的窄走道,貨架高六公尺,地面有反光標線",
|
| 5 |
+
robot: "配備雙目相機與夾爪的自主移動機器人",
|
| 6 |
+
task: "從右側貨架取出藍色料箱,避開移動中的堆高機,放到揀貨站",
|
| 7 |
+
condition: "夜班低照度,局部逆光,地面有少量積水",
|
| 8 |
+
hazard: "一名穿反光背心的工作人員突然從貨架後方走入路徑",
|
| 9 |
+
},
|
| 10 |
+
sidewalk: {
|
| 11 |
+
name: "雨天末端配送",
|
| 12 |
+
environment: "台北市區騎樓與人行道交界,路面濕滑且有坡道",
|
| 13 |
+
robot: "六輪自動配送機器人,前向廣角相機視角",
|
| 14 |
+
task: "沿人行道前進並在便利商店入口前安全停靠",
|
| 15 |
+
condition: "傍晚大雨,車燈反射,行人撐傘造成遮擋",
|
| 16 |
+
hazard: "一台機車從騎樓出口快速切入機器人前方",
|
| 17 |
+
},
|
| 18 |
+
factory: {
|
| 19 |
+
name: "協作機械臂裝配",
|
| 20 |
+
environment: "電子工廠裝配站,桌面有零件盤、治具與安全光柵",
|
| 21 |
+
robot: "固定式七軸協作機械臂,腕部相機視角",
|
| 22 |
+
task: "拾取金屬接頭並插入黑色治具,完成後退回安全姿態",
|
| 23 |
+
condition: "均勻白光,金屬表面強反射,背景輸送帶持續移動",
|
| 24 |
+
hazard: "零件在夾取後滑動,操作員的手同時接近工作區",
|
| 25 |
+
},
|
| 26 |
+
};
|
| 27 |
+
|
| 28 |
+
const CAMERA = [
|
| 29 |
+
"camera follows at waist height with stable forward motion",
|
| 30 |
+
"wide-angle fixed camera shows the complete interaction zone",
|
| 31 |
+
"robot ego-view with natural vibration and realistic motion blur",
|
| 32 |
+
"three-quarter tracking shot preserving spatial continuity",
|
| 33 |
+
];
|
| 34 |
+
|
| 35 |
+
const LIGHT = [
|
| 36 |
+
"physically plausible global illumination",
|
| 37 |
+
"subtle exposure adaptation and realistic reflections",
|
| 38 |
+
"natural volumetric light with accurate contact shadows",
|
| 39 |
+
"documentary lighting with high material fidelity",
|
| 40 |
+
];
|
| 41 |
+
|
| 42 |
+
export function validateScenario(input) {
|
| 43 |
+
const required = ["name", "environment", "robot", "task"];
|
| 44 |
+
return required
|
| 45 |
+
.filter((key) => !String(input[key] || "").trim())
|
| 46 |
+
.map((key) => ({ field: key, message: `請填寫${{ name: "情境名稱", environment: "環境", robot: "機器人", task: "任務" }[key]}` }));
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
export function buildPrompt(input, index = 0) {
|
| 50 |
+
const seed = Number(input.seed || 7) + index * 17;
|
| 51 |
+
const camera = CAMERA[Math.abs(seed) % CAMERA.length];
|
| 52 |
+
const light = LIGHT[Math.abs(seed * 3 + index) % LIGHT.length];
|
| 53 |
+
const condition = input.condition?.trim() || "clear visibility and normal operating conditions";
|
| 54 |
+
const hazard = input.hazard?.trim() || "no unexpected hazard";
|
| 55 |
+
return [
|
| 56 |
+
`A photorealistic physical AI simulation in ${input.environment.trim()}.`,
|
| 57 |
+
`${input.robot.trim()} must ${input.task.trim()}.`,
|
| 58 |
+
`Operating condition: ${condition}.`,
|
| 59 |
+
`Critical event: ${hazard}.`,
|
| 60 |
+
`${camera}; ${light}.`,
|
| 61 |
+
"Continuous causal motion, consistent geometry, realistic physics, safety-relevant interactions, no cuts.",
|
| 62 |
+
].join(" ");
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
export function buildNegativePrompt() {
|
| 66 |
+
return [
|
| 67 |
+
"cartoon, illustration, impossible geometry, teleportation, duplicated objects",
|
| 68 |
+
"floating objects, broken kinematics, inconsistent robot morphology",
|
| 69 |
+
"camera cuts, excessive motion blur, text overlays, watermark, low detail",
|
| 70 |
+
].join(", ");
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
export function buildVariant(input, index) {
|
| 74 |
+
const prompt = buildPrompt(input, index);
|
| 75 |
+
const seed = Number(input.seed || 7) + index * 17;
|
| 76 |
+
return {
|
| 77 |
+
id: `world-${String(index + 1).padStart(2, "0")}`,
|
| 78 |
+
seed,
|
| 79 |
+
prompt,
|
| 80 |
+
negative_prompt: buildNegativePrompt(),
|
| 81 |
+
tags: [input.robot, input.condition, input.hazard].filter(Boolean),
|
| 82 |
+
request: {
|
| 83 |
+
prompt,
|
| 84 |
+
negative_prompt: buildNegativePrompt(),
|
| 85 |
+
seed,
|
| 86 |
+
resolution: input.resolution || "720",
|
| 87 |
+
num_output_frames: Number(input.frames || 121),
|
| 88 |
+
guidance_scale: 7.0,
|
| 89 |
+
steps: 35,
|
| 90 |
+
},
|
| 91 |
+
};
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
export function buildManifest(input) {
|
| 95 |
+
const count = Math.min(12, Math.max(1, Number(input.variants || 4)));
|
| 96 |
+
return {
|
| 97 |
+
schema_version: "1.0",
|
| 98 |
+
project: input.name.trim(),
|
| 99 |
+
generator: "NVIDIA Cosmos-Predict2.5",
|
| 100 |
+
created_at: new Date().toISOString(),
|
| 101 |
+
intent: "physical-ai synthetic scenario generation",
|
| 102 |
+
variants: Array.from({ length: count }, (_, index) => buildVariant(input, index)),
|
| 103 |
+
};
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
export function riskScore(input) {
|
| 107 |
+
let score = 28;
|
| 108 |
+
const text = `${input.condition || ""} ${input.hazard || ""}`.toLowerCase();
|
| 109 |
+
["雨", "低照", "逆光", "遮擋", "突然", "快速", "滑", "人", "forklift", "occlusion"].forEach((term) => {
|
| 110 |
+
if (text.includes(term)) score += 7;
|
| 111 |
+
});
|
| 112 |
+
return Math.min(96, score);
|
| 113 |
+
}
|
model.test.mjs
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import test from "node:test";
|
| 2 |
+
import assert from "node:assert/strict";
|
| 3 |
+
import { PRESETS, buildManifest, buildPrompt, riskScore, validateScenario } from "./model.mjs";
|
| 4 |
+
|
| 5 |
+
test("valid preset creates deterministic Cosmos variants", () => {
|
| 6 |
+
const input = { ...PRESETS.warehouse, seed: 9, variants: 3, frames: 121, resolution: "720" };
|
| 7 |
+
const manifest = buildManifest(input);
|
| 8 |
+
assert.equal(manifest.variants.length, 3);
|
| 9 |
+
assert.equal(manifest.variants[0].seed, 9);
|
| 10 |
+
assert.equal(manifest.variants[1].seed, 26);
|
| 11 |
+
assert.equal(manifest.variants[0].request.resolution, "720");
|
| 12 |
+
assert.equal(manifest.variants[0].request.guidance_scale, 7);
|
| 13 |
+
assert.match(buildPrompt(input), /Continuous causal motion/);
|
| 14 |
+
});
|
| 15 |
+
|
| 16 |
+
test("missing core fields are reported", () => {
|
| 17 |
+
assert.deepEqual(validateScenario({ name: "", environment: "", robot: "AMR", task: "" }).map((e) => e.field), [
|
| 18 |
+
"name",
|
| 19 |
+
"environment",
|
| 20 |
+
"task",
|
| 21 |
+
]);
|
| 22 |
+
});
|
| 23 |
+
|
| 24 |
+
test("risk score is bounded and reacts to hazards", () => {
|
| 25 |
+
assert.ok(riskScore({ condition: "低照度大雨", hazard: "人突然快速切入" }) > riskScore({ condition: "clear", hazard: "" }));
|
| 26 |
+
assert.ok(riskScore({ condition: "雨 低照 逆光 遮擋", hazard: "人突然快速滑入" }) <= 96);
|
| 27 |
+
});
|
run_warehouse_acceptance.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build and validate the Warehouse Franka scene with an Isaac Sim 5.1 install."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import platform
|
| 7 |
+
import subprocess
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def main() -> None:
|
| 12 |
+
parser = argparse.ArgumentParser(description="Run Warehouse Franka physical acceptance")
|
| 13 |
+
parser.add_argument("--isaac-root", required=True, help="Isaac Sim 5.1 installation directory")
|
| 14 |
+
parser.add_argument("--package", required=True, help="Cosmos Isaac work-package ZIP")
|
| 15 |
+
parser.add_argument("--episodes", type=int, default=3)
|
| 16 |
+
parser.add_argument("--gui", action="store_true", help="Show the Isaac Sim viewport")
|
| 17 |
+
args = parser.parse_args()
|
| 18 |
+
|
| 19 |
+
if platform.system() != "Linux":
|
| 20 |
+
raise SystemExit("Isaac Sim 5.1 physical acceptance requires Linux x86_64 with a supported NVIDIA RTX GPU.")
|
| 21 |
+
root = Path(args.isaac_root).expanduser().resolve()
|
| 22 |
+
python = root / "python.sh"
|
| 23 |
+
package = Path(args.package).expanduser().resolve()
|
| 24 |
+
if not python.is_file():
|
| 25 |
+
raise SystemExit(f"Isaac Python launcher not found: {python}")
|
| 26 |
+
if not package.is_file():
|
| 27 |
+
raise SystemExit(f"Work package not found: {package}")
|
| 28 |
+
|
| 29 |
+
project = Path(__file__).resolve().parent
|
| 30 |
+
artifacts = project / "artifacts"
|
| 31 |
+
scene = artifacts / "warehouse_franka.usd"
|
| 32 |
+
mapping = artifacts / "warehouse_franka.mapping.json"
|
| 33 |
+
metrics = artifacts / "warehouse_franka.metrics.json"
|
| 34 |
+
headless = [] if args.gui else ["--headless"]
|
| 35 |
+
|
| 36 |
+
subprocess.run(
|
| 37 |
+
[str(python), "-m", "isaac_adapter.build_warehouse_scene", "--output", str(scene), "--mapping", str(mapping), *headless],
|
| 38 |
+
cwd=project,
|
| 39 |
+
check=True,
|
| 40 |
+
)
|
| 41 |
+
subprocess.run(
|
| 42 |
+
[
|
| 43 |
+
str(python),
|
| 44 |
+
"-m",
|
| 45 |
+
"isaac_adapter.runtime",
|
| 46 |
+
"--package",
|
| 47 |
+
str(package),
|
| 48 |
+
"--mapping",
|
| 49 |
+
str(mapping),
|
| 50 |
+
"--episodes",
|
| 51 |
+
str(max(1, min(100, args.episodes))),
|
| 52 |
+
"--output",
|
| 53 |
+
str(metrics),
|
| 54 |
+
*headless,
|
| 55 |
+
],
|
| 56 |
+
cwd=project,
|
| 57 |
+
check=True,
|
| 58 |
+
)
|
| 59 |
+
print(f"Metrics written to {metrics}")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
if __name__ == "__main__":
|
| 63 |
+
main()
|
scene_mapping.example.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema_version": "1.0",
|
| 3 |
+
"scene_usd": "/absolute/path/to/warehouse_scene.usd",
|
| 4 |
+
"policy": "franka_pick_place",
|
| 5 |
+
"prims": {
|
| 6 |
+
"robot": "/World/Franka",
|
| 7 |
+
"collision_parent": "/World/Franka/panda_link0",
|
| 8 |
+
"task_object": "/World/TaskObject",
|
| 9 |
+
"task_target": "/World/TaskTarget",
|
| 10 |
+
"hazard": "/World/Hazard",
|
| 11 |
+
"camera": "/World/Camera",
|
| 12 |
+
"lights": ["/World/KeyLight"]
|
| 13 |
+
},
|
| 14 |
+
"clearance_radii_m": {
|
| 15 |
+
"robot": 0.32,
|
| 16 |
+
"hazard": 0.3
|
| 17 |
+
},
|
| 18 |
+
"success_tolerance_m": 0.035,
|
| 19 |
+
"end_effector_offset": [0, 0, 0.2],
|
| 20 |
+
"max_steps": 4000
|
| 21 |
+
}
|
scene_mapping.warehouse.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"schema_version": "1.0",
|
| 3 |
+
"scene_usd": "artifacts/warehouse_franka.usd",
|
| 4 |
+
"policy": "franka_pick_place",
|
| 5 |
+
"prims": {
|
| 6 |
+
"robot": "/World/Franka",
|
| 7 |
+
"collision_parent": "/World/Franka/panda_link0",
|
| 8 |
+
"collision_parents": [
|
| 9 |
+
"/World/Franka/panda_link0",
|
| 10 |
+
"/World/Franka/panda_link1",
|
| 11 |
+
"/World/Franka/panda_link2",
|
| 12 |
+
"/World/Franka/panda_link3",
|
| 13 |
+
"/World/Franka/panda_link4",
|
| 14 |
+
"/World/Franka/panda_link5",
|
| 15 |
+
"/World/Franka/panda_link6",
|
| 16 |
+
"/World/Franka/panda_link7",
|
| 17 |
+
"/World/Franka/panda_hand"
|
| 18 |
+
],
|
| 19 |
+
"task_object": "/World/TaskObject",
|
| 20 |
+
"task_target": "/World/TaskTarget",
|
| 21 |
+
"hazard": "/World/Hazard",
|
| 22 |
+
"camera": "/World/Camera",
|
| 23 |
+
"lights": ["/World/KeyLight", "/World/FillLight"]
|
| 24 |
+
},
|
| 25 |
+
"clearance_radii_m": {"robot": 0.07, "hazard": 0.08},
|
| 26 |
+
"success_tolerance_m": 0.045,
|
| 27 |
+
"end_effector_offset": [0.0, 0.0, 0.10],
|
| 28 |
+
"max_steps": 4000
|
| 29 |
+
}
|
server.py
ADDED
|
@@ -0,0 +1,561 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Local static server and asynchronous proxy for NVIDIA Cosmos NIM."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import argparse
|
| 6 |
+
import base64
|
| 7 |
+
import binascii
|
| 8 |
+
import json
|
| 9 |
+
import mimetypes
|
| 10 |
+
import os
|
| 11 |
+
import re
|
| 12 |
+
import threading
|
| 13 |
+
import time
|
| 14 |
+
import uuid
|
| 15 |
+
import zipfile
|
| 16 |
+
from concurrent.futures import ThreadPoolExecutor
|
| 17 |
+
from dataclasses import asdict, dataclass, field
|
| 18 |
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Any
|
| 21 |
+
from urllib.error import HTTPError, URLError
|
| 22 |
+
from urllib.parse import urlparse
|
| 23 |
+
from urllib.request import Request, urlopen
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
ROOT = Path(__file__).resolve().parent
|
| 27 |
+
OUTPUT_DIR = ROOT / "outputs"
|
| 28 |
+
ISAAC_DIR = OUTPUT_DIR / "isaac"
|
| 29 |
+
TERMINAL = {"succeeded", "failed"}
|
| 30 |
+
RETRYABLE_HTTP = {408, 425, 429, 500, 502, 503, 504}
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
@dataclass
|
| 34 |
+
class Job:
|
| 35 |
+
id: str
|
| 36 |
+
variant_id: str
|
| 37 |
+
status: str = "queued"
|
| 38 |
+
attempts: int = 0
|
| 39 |
+
max_retries: int = 2
|
| 40 |
+
progress: int = 0
|
| 41 |
+
message: str = "等待 worker"
|
| 42 |
+
output_url: str | None = None
|
| 43 |
+
error: str | None = None
|
| 44 |
+
created_at: float = field(default_factory=time.time)
|
| 45 |
+
updated_at: float = field(default_factory=time.time)
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class JobStore:
|
| 49 |
+
def __init__(self) -> None:
|
| 50 |
+
self._jobs: dict[str, Job] = {}
|
| 51 |
+
self._lock = threading.Lock()
|
| 52 |
+
|
| 53 |
+
def create(self, variant_id: str, max_retries: int) -> Job:
|
| 54 |
+
job = Job(id=uuid.uuid4().hex, variant_id=variant_id, max_retries=max_retries)
|
| 55 |
+
with self._lock:
|
| 56 |
+
self._jobs[job.id] = job
|
| 57 |
+
return job
|
| 58 |
+
|
| 59 |
+
def get(self, job_id: str) -> Job | None:
|
| 60 |
+
with self._lock:
|
| 61 |
+
return self._jobs.get(job_id)
|
| 62 |
+
|
| 63 |
+
def update(self, job_id: str, **changes: Any) -> Job:
|
| 64 |
+
with self._lock:
|
| 65 |
+
job = self._jobs[job_id]
|
| 66 |
+
for key, value in changes.items():
|
| 67 |
+
setattr(job, key, value)
|
| 68 |
+
job.updated_at = time.time()
|
| 69 |
+
return job
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
STORE = JobStore()
|
| 73 |
+
EXECUTOR = ThreadPoolExecutor(max_workers=2, thread_name_prefix="cosmos-nim")
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@dataclass
|
| 77 |
+
class Evaluation:
|
| 78 |
+
id: str
|
| 79 |
+
job_id: str
|
| 80 |
+
variant_id: str
|
| 81 |
+
status: str = "queued"
|
| 82 |
+
message: str = "等待 Cosmos Reason2"
|
| 83 |
+
scores: dict[str, Any] | None = None
|
| 84 |
+
error: str | None = None
|
| 85 |
+
created_at: float = field(default_factory=time.time)
|
| 86 |
+
updated_at: float = field(default_factory=time.time)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
class EvaluationStore:
|
| 90 |
+
def __init__(self) -> None:
|
| 91 |
+
self._items: dict[str, Evaluation] = {}
|
| 92 |
+
self._lock = threading.Lock()
|
| 93 |
+
|
| 94 |
+
def create(self, job_id: str, variant_id: str) -> Evaluation:
|
| 95 |
+
item = Evaluation(id=uuid.uuid4().hex, job_id=job_id, variant_id=variant_id)
|
| 96 |
+
with self._lock:
|
| 97 |
+
self._items[item.id] = item
|
| 98 |
+
return item
|
| 99 |
+
|
| 100 |
+
def get(self, evaluation_id: str) -> Evaluation | None:
|
| 101 |
+
with self._lock:
|
| 102 |
+
return self._items.get(evaluation_id)
|
| 103 |
+
|
| 104 |
+
def update(self, evaluation_id: str, **changes: Any) -> Evaluation:
|
| 105 |
+
with self._lock:
|
| 106 |
+
item = self._items[evaluation_id]
|
| 107 |
+
for key, value in changes.items():
|
| 108 |
+
setattr(item, key, value)
|
| 109 |
+
item.updated_at = time.time()
|
| 110 |
+
return item
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
EVALUATIONS = EvaluationStore()
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
@dataclass
|
| 117 |
+
class IsaacRun:
|
| 118 |
+
id: str
|
| 119 |
+
package_path: str
|
| 120 |
+
status: str = "queued"
|
| 121 |
+
backend: str = "isaac"
|
| 122 |
+
episodes_per_variant: int = 3
|
| 123 |
+
message: str = "等待 Scene Adapter"
|
| 124 |
+
telemetry: dict[str, Any] | None = None
|
| 125 |
+
result: dict[str, Any] | None = None
|
| 126 |
+
created_at: float = field(default_factory=time.time)
|
| 127 |
+
updated_at: float = field(default_factory=time.time)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
class IsaacRunStore:
|
| 131 |
+
def __init__(self) -> None:
|
| 132 |
+
self._runs: dict[str, IsaacRun] = {}
|
| 133 |
+
self._lock = threading.Lock()
|
| 134 |
+
|
| 135 |
+
def create(self, package_path: str, backend: str, episodes_per_variant: int) -> IsaacRun:
|
| 136 |
+
run = IsaacRun(id=uuid.uuid4().hex, package_path=package_path, backend=backend, episodes_per_variant=episodes_per_variant)
|
| 137 |
+
with self._lock:
|
| 138 |
+
self._runs[run.id] = run
|
| 139 |
+
return run
|
| 140 |
+
|
| 141 |
+
def get(self, run_id: str) -> IsaacRun | None:
|
| 142 |
+
with self._lock:
|
| 143 |
+
return self._runs.get(run_id)
|
| 144 |
+
|
| 145 |
+
def next_queued(self) -> IsaacRun | None:
|
| 146 |
+
with self._lock:
|
| 147 |
+
run = next((item for item in self._runs.values() if item.status == "queued"), None)
|
| 148 |
+
if run:
|
| 149 |
+
run.status = "running"
|
| 150 |
+
run.message = "Scene Adapter 已領取工作"
|
| 151 |
+
run.updated_at = time.time()
|
| 152 |
+
return run
|
| 153 |
+
|
| 154 |
+
def update(self, run_id: str, **changes: Any) -> IsaacRun:
|
| 155 |
+
with self._lock:
|
| 156 |
+
run = self._runs[run_id]
|
| 157 |
+
for key, value in changes.items():
|
| 158 |
+
setattr(run, key, value)
|
| 159 |
+
run.updated_at = time.time()
|
| 160 |
+
return run
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
ISAAC_RUNS = IsaacRunStore()
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
REASON_PROMPT = """Analyze this Physical AI simulation video against the intended scenario.
|
| 167 |
+
Return JSON only, with integer scores from 0 to 100:
|
| 168 |
+
{
|
| 169 |
+
"collision_risk": 0,
|
| 170 |
+
"physics_plausibility": 0,
|
| 171 |
+
"task_success": 0,
|
| 172 |
+
"danger_severity": 0,
|
| 173 |
+
"observed_event": "short factual observation",
|
| 174 |
+
"rationale": "short justification grounded in visible evidence",
|
| 175 |
+
"recommended_test": "one Isaac Sim validation to run next"
|
| 176 |
+
}
|
| 177 |
+
collision_risk and danger_severity are higher when worse; physics_plausibility
|
| 178 |
+
and task_success are higher when better. Do not invent events not visible.
|
| 179 |
+
|
| 180 |
+
Intended scenario:
|
| 181 |
+
"""
|
| 182 |
+
|
| 183 |
+
|
| 184 |
+
def validate_endpoint(endpoint: str) -> str:
|
| 185 |
+
parsed = urlparse(endpoint)
|
| 186 |
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
| 187 |
+
raise ValueError("NIM endpoint 必須是完整的 http 或 https URL")
|
| 188 |
+
return endpoint
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def nim_health(endpoint: str, api_key: str = "", timeout: int = 10) -> dict[str, Any]:
|
| 192 |
+
parsed = urlparse(validate_endpoint(endpoint))
|
| 193 |
+
health_url = parsed._replace(path="/v1/health/ready", query="", fragment="").geturl()
|
| 194 |
+
headers = {"Accept": "application/json"}
|
| 195 |
+
if api_key:
|
| 196 |
+
headers["Authorization"] = f"Bearer {api_key}"
|
| 197 |
+
request = Request(health_url, headers=headers)
|
| 198 |
+
started = time.monotonic()
|
| 199 |
+
with urlopen(request, timeout=timeout) as response:
|
| 200 |
+
raw = response.read()
|
| 201 |
+
payload = json.loads(raw or b"{}")
|
| 202 |
+
ready = payload.get("status") == "ready" or payload.get("ready") is True
|
| 203 |
+
return {"ready": ready, "latency_ms": round((time.monotonic() - started) * 1000), "details": payload}
|
| 204 |
+
|
| 205 |
+
|
| 206 |
+
def request_inference(endpoint: str, payload: dict[str, Any], api_key: str, timeout: int) -> bytes:
|
| 207 |
+
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
| 208 |
+
if api_key:
|
| 209 |
+
headers["Authorization"] = f"Bearer {api_key}"
|
| 210 |
+
request = Request(validate_endpoint(endpoint), data=json.dumps(payload).encode(), headers=headers, method="POST")
|
| 211 |
+
with urlopen(request, timeout=timeout) as response:
|
| 212 |
+
raw = response.read()
|
| 213 |
+
result = json.loads(raw)
|
| 214 |
+
if result.get("error"):
|
| 215 |
+
raise RuntimeError(str(result["error"]))
|
| 216 |
+
encoded = result.get("b64_video")
|
| 217 |
+
if not encoded:
|
| 218 |
+
raise RuntimeError("NIM response 缺少 b64_video")
|
| 219 |
+
try:
|
| 220 |
+
return base64.b64decode(encoded, validate=True)
|
| 221 |
+
except (binascii.Error, ValueError) as exc:
|
| 222 |
+
raise RuntimeError("NIM response 的 b64_video 無效") from exc
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def extract_json(text: str) -> dict[str, Any]:
|
| 226 |
+
text = re.sub(r"<think>.*?</think>", "", text, flags=re.DOTALL).strip()
|
| 227 |
+
fenced = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, flags=re.DOTALL)
|
| 228 |
+
if fenced:
|
| 229 |
+
text = fenced.group(1)
|
| 230 |
+
else:
|
| 231 |
+
start, end = text.find("{"), text.rfind("}")
|
| 232 |
+
if start < 0 or end <= start:
|
| 233 |
+
raise RuntimeError("Reason2 response 不包含 JSON")
|
| 234 |
+
text = text[start : end + 1]
|
| 235 |
+
return json.loads(text)
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
def normalize_scores(result: dict[str, Any]) -> dict[str, Any]:
|
| 239 |
+
normalized: dict[str, Any] = {}
|
| 240 |
+
for name in ["collision_risk", "physics_plausibility", "task_success", "danger_severity"]:
|
| 241 |
+
try:
|
| 242 |
+
normalized[name] = min(100, max(0, int(round(float(result[name])))))
|
| 243 |
+
except (KeyError, TypeError, ValueError) as exc:
|
| 244 |
+
raise RuntimeError(f"Reason2 response 缺少有效分數:{name}") from exc
|
| 245 |
+
normalized["observed_event"] = str(result.get("observed_event", "未提供觀察"))[:500]
|
| 246 |
+
normalized["rationale"] = str(result.get("rationale", "未提供理由"))[:1000]
|
| 247 |
+
normalized["recommended_test"] = str(result.get("recommended_test", "重播此情境並記錄最小安全距離"))[:500]
|
| 248 |
+
normalized["priority"] = round(
|
| 249 |
+
normalized["collision_risk"] * 0.30
|
| 250 |
+
+ normalized["danger_severity"] * 0.30
|
| 251 |
+
+ (100 - normalized["physics_plausibility"]) * 0.20
|
| 252 |
+
+ (100 - normalized["task_success"]) * 0.20
|
| 253 |
+
)
|
| 254 |
+
return normalized
|
| 255 |
+
|
| 256 |
+
|
| 257 |
+
def request_reason2(endpoint: str, video: bytes, scenario: dict[str, Any], api_key: str, timeout: int) -> dict[str, Any]:
|
| 258 |
+
headers = {"Accept": "application/json", "Content-Type": "application/json"}
|
| 259 |
+
if api_key:
|
| 260 |
+
headers["Authorization"] = f"Bearer {api_key}"
|
| 261 |
+
payload = {
|
| 262 |
+
"model": "nvidia/cosmos-reason2-2b",
|
| 263 |
+
"messages": [
|
| 264 |
+
{"role": "system", "content": "You are a rigorous Physical AI safety evaluator. Return structured evidence, not hidden reasoning."},
|
| 265 |
+
{"role": "user", "content": [
|
| 266 |
+
{"type": "video_url", "video_url": {"url": "data:video/mp4;base64," + base64.b64encode(video).decode()}},
|
| 267 |
+
{"type": "text", "text": REASON_PROMPT + json.dumps(scenario, ensure_ascii=False)},
|
| 268 |
+
]},
|
| 269 |
+
],
|
| 270 |
+
"media_io_kwargs": {"video": {"fps": 2.0}},
|
| 271 |
+
"max_tokens": 1024,
|
| 272 |
+
"temperature": 0.1,
|
| 273 |
+
"stream": False,
|
| 274 |
+
}
|
| 275 |
+
request = Request(validate_endpoint(endpoint), data=json.dumps(payload).encode(), headers=headers, method="POST")
|
| 276 |
+
with urlopen(request, timeout=timeout) as response:
|
| 277 |
+
raw = json.loads(response.read())
|
| 278 |
+
try:
|
| 279 |
+
content = raw["choices"][0]["message"]["content"]
|
| 280 |
+
except (KeyError, IndexError, TypeError) as exc:
|
| 281 |
+
raise RuntimeError("Reason2 response 缺少 choices[0].message.content") from exc
|
| 282 |
+
return normalize_scores(extract_json(content))
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def run_evaluation(evaluation_id: str, reason_endpoint: str, scenario: dict[str, Any], api_key: str, timeout: int) -> None:
|
| 286 |
+
item = EVALUATIONS.get(evaluation_id)
|
| 287 |
+
if not item:
|
| 288 |
+
return
|
| 289 |
+
job = STORE.get(item.job_id)
|
| 290 |
+
if not job or job.status != "succeeded" or not job.output_url:
|
| 291 |
+
EVALUATIONS.update(evaluation_id, status="failed", message="找不到成功的生成影片", error="generation job unavailable")
|
| 292 |
+
return
|
| 293 |
+
try:
|
| 294 |
+
EVALUATIONS.update(evaluation_id, status="running", message="Reason2 正在分析時空與物理事件")
|
| 295 |
+
video_path = ROOT / job.output_url.lstrip("/")
|
| 296 |
+
scores = request_reason2(reason_endpoint, video_path.read_bytes(), scenario, api_key, timeout)
|
| 297 |
+
EVALUATIONS.update(evaluation_id, status="succeeded", message="評分完成", scores=scores, error=None)
|
| 298 |
+
except Exception as exc:
|
| 299 |
+
EVALUATIONS.update(evaluation_id, status="failed", message="Reason2 評分失敗", error=str(exc))
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
def build_isaac_package(manifest: dict[str, Any], evaluations: list[dict[str, Any]]) -> Path:
|
| 303 |
+
package_id = uuid.uuid4().hex[:12]
|
| 304 |
+
package_dir = ISAAC_DIR / package_id
|
| 305 |
+
package_dir.mkdir(parents=True, exist_ok=True)
|
| 306 |
+
by_variant = {item.get("variant_id"): item.get("scores", {}) for item in evaluations}
|
| 307 |
+
ranked = sorted(manifest.get("variants", []), key=lambda item: by_variant.get(item.get("id"), {}).get("priority", 0), reverse=True)
|
| 308 |
+
max_priority = max((by_variant.get(item.get("id"), {}).get("priority", 0) for item in ranked), default=50)
|
| 309 |
+
stress = max_priority / 100
|
| 310 |
+
randomization = {
|
| 311 |
+
"schema_version": "1.0",
|
| 312 |
+
"seed": ranked[0].get("seed", 7) if ranked else 7,
|
| 313 |
+
"source": "Cosmos Scenario Studio + Cosmos Reason2",
|
| 314 |
+
"lighting": {"intensity_lux": [round(300 - 100 * stress), round(950 + 250 * stress)], "color_temperature_k": [3500, 7000]},
|
| 315 |
+
"camera": {"translation_noise_m": [-round(0.01 + 0.02 * stress, 3), round(0.01 + 0.02 * stress, 3)], "rotation_noise_deg": [-round(1 + 3 * stress, 1), round(1 + 3 * stress, 1)]},
|
| 316 |
+
"object": {"xy_position_noise_m": [-round(0.015 + 0.04 * stress, 3), round(0.015 + 0.04 * stress, 3)], "yaw_noise_deg": [-round(8 + 16 * stress), round(8 + 16 * stress)]},
|
| 317 |
+
"physics": {"friction": [round(0.45 - 0.15 * stress, 2), 1.0], "payload_mass_kg": [0.02, round(0.06 + 0.08 * stress, 2)], "joint_damping_scale": [round(0.9 - 0.2 * stress, 2), round(1.1 + 0.2 * stress, 2)]},
|
| 318 |
+
}
|
| 319 |
+
validation = {
|
| 320 |
+
"schema_version": "1.0",
|
| 321 |
+
"policy": "fail-closed",
|
| 322 |
+
"episodes_per_variant": 25,
|
| 323 |
+
"acceptance": {"minimum_success_rate": 90, "maximum_collision_rate": 0, "minimum_clearance_m": 0.15, "maximum_joint_error_deg": 8},
|
| 324 |
+
"ranked_edge_cases": [
|
| 325 |
+
{"rank": index + 1, "variant_id": variant.get("id"), "seed": variant.get("seed"), "priority": by_variant.get(variant.get("id"), {}).get("priority"), "reason2": by_variant.get(variant.get("id"), {}), "recommended_test": by_variant.get(variant.get("id"), {}).get("recommended_test")}
|
| 326 |
+
for index, variant in enumerate(ranked)
|
| 327 |
+
],
|
| 328 |
+
}
|
| 329 |
+
scene_mapping = {
|
| 330 |
+
"schema_version": "1.0",
|
| 331 |
+
"scene_usd": "/absolute/path/to/scene.usd",
|
| 332 |
+
"policy": "franka_pick_place",
|
| 333 |
+
"prims": {
|
| 334 |
+
"robot": "/World/Franka",
|
| 335 |
+
"collision_parent": "/World/Franka/panda_link0",
|
| 336 |
+
"task_object": "/World/TaskObject",
|
| 337 |
+
"task_target": "/World/TaskTarget",
|
| 338 |
+
"hazard": "/World/Hazard",
|
| 339 |
+
"camera": "/World/Camera",
|
| 340 |
+
"lights": ["/World/KeyLight"],
|
| 341 |
+
},
|
| 342 |
+
"clearance_radii_m": {"robot": 0.32, "hazard": 0.30},
|
| 343 |
+
"success_tolerance_m": 0.035,
|
| 344 |
+
"max_steps": 4000,
|
| 345 |
+
}
|
| 346 |
+
(package_dir / "cosmos_manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2))
|
| 347 |
+
(package_dir / "domain_randomization.json").write_text(json.dumps(randomization, ensure_ascii=False, indent=2))
|
| 348 |
+
(package_dir / "policy_validation.json").write_text(json.dumps(validation, ensure_ascii=False, indent=2))
|
| 349 |
+
(package_dir / "scene_mapping.example.json").write_text(json.dumps(scene_mapping, ensure_ascii=False, indent=2))
|
| 350 |
+
archive = ISAAC_DIR / f"isaac-work-package-{package_id}.zip"
|
| 351 |
+
with zipfile.ZipFile(archive, "w", zipfile.ZIP_DEFLATED) as bundle:
|
| 352 |
+
for path in package_dir.iterdir():
|
| 353 |
+
bundle.write(path, arcname=path.name)
|
| 354 |
+
return archive
|
| 355 |
+
|
| 356 |
+
|
| 357 |
+
def is_retryable(error: Exception) -> bool:
|
| 358 |
+
if isinstance(error, HTTPError):
|
| 359 |
+
return error.code in RETRYABLE_HTTP
|
| 360 |
+
return isinstance(error, (URLError, TimeoutError))
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
def run_job(job_id: str, endpoint: str, payload: dict[str, Any], api_key: str, timeout: int) -> None:
|
| 364 |
+
job = STORE.get(job_id)
|
| 365 |
+
if not job:
|
| 366 |
+
return
|
| 367 |
+
OUTPUT_DIR.mkdir(exist_ok=True)
|
| 368 |
+
total_attempts = job.max_retries + 1
|
| 369 |
+
for attempt in range(1, total_attempts + 1):
|
| 370 |
+
status = "running" if attempt == 1 else "retrying"
|
| 371 |
+
STORE.update(job_id, status=status, attempts=attempt, progress=10, message=f"NIM 推論中 · 第 {attempt}/{total_attempts} 次")
|
| 372 |
+
try:
|
| 373 |
+
video = request_inference(endpoint, payload, api_key, timeout)
|
| 374 |
+
target = OUTPUT_DIR / f"{job_id}.mp4"
|
| 375 |
+
temporary = target.with_suffix(".tmp")
|
| 376 |
+
temporary.write_bytes(video)
|
| 377 |
+
temporary.replace(target)
|
| 378 |
+
STORE.update(job_id, status="succeeded", progress=100, message="影片已保存", output_url=f"/outputs/{target.name}", error=None)
|
| 379 |
+
return
|
| 380 |
+
except Exception as exc: # background boundary: convert failures to job state
|
| 381 |
+
retry = attempt < total_attempts and is_retryable(exc)
|
| 382 |
+
if retry:
|
| 383 |
+
delay = min(8, 2 ** (attempt - 1))
|
| 384 |
+
STORE.update(job_id, status="retrying", progress=25, message=f"暫時性錯誤,{delay}s 後重試", error=str(exc))
|
| 385 |
+
time.sleep(delay)
|
| 386 |
+
continue
|
| 387 |
+
STORE.update(job_id, status="failed", progress=100, message="生成失敗", error=str(exc))
|
| 388 |
+
return
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
class Handler(SimpleHTTPRequestHandler):
|
| 392 |
+
server_version = "CosmosScenarioStudio/3.0"
|
| 393 |
+
|
| 394 |
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
| 395 |
+
super().__init__(*args, directory=str(ROOT), **kwargs)
|
| 396 |
+
|
| 397 |
+
def send_json(self, status: int, payload: dict[str, Any]) -> None:
|
| 398 |
+
body = json.dumps(payload, ensure_ascii=False).encode()
|
| 399 |
+
self.send_response(status)
|
| 400 |
+
self.send_header("Content-Type", "application/json; charset=utf-8")
|
| 401 |
+
self.send_header("Content-Length", str(len(body)))
|
| 402 |
+
self.send_header("Cache-Control", "no-store")
|
| 403 |
+
self.end_headers()
|
| 404 |
+
self.wfile.write(body)
|
| 405 |
+
|
| 406 |
+
def read_json(self) -> dict[str, Any]:
|
| 407 |
+
length = int(self.headers.get("Content-Length", "0"))
|
| 408 |
+
if length > 2_000_000:
|
| 409 |
+
raise ValueError("request body 過大")
|
| 410 |
+
return json.loads(self.rfile.read(length) or b"{}")
|
| 411 |
+
|
| 412 |
+
def do_GET(self) -> None: # noqa: N802
|
| 413 |
+
if self.path == "/api/isaac/command":
|
| 414 |
+
run = ISAAC_RUNS.next_queued()
|
| 415 |
+
if not run:
|
| 416 |
+
self.send_json(200, {"type": "idle"})
|
| 417 |
+
return
|
| 418 |
+
self.send_json(200, {"type": "run", "run_id": run.id, "package_path": run.package_path, "backend": run.backend, "episodes_per_variant": run.episodes_per_variant})
|
| 419 |
+
return
|
| 420 |
+
if self.path.startswith("/api/isaac/runs/"):
|
| 421 |
+
run_id = self.path.removeprefix("/api/isaac/runs/").split("/", 1)[0].split("?", 1)[0]
|
| 422 |
+
run = ISAAC_RUNS.get(run_id)
|
| 423 |
+
if not run:
|
| 424 |
+
self.send_json(404, {"error": "Isaac run not found"})
|
| 425 |
+
return
|
| 426 |
+
self.send_json(200, asdict(run))
|
| 427 |
+
return
|
| 428 |
+
if self.path.startswith("/api/evaluations/"):
|
| 429 |
+
evaluation_id = self.path.removeprefix("/api/evaluations/").split("?", 1)[0]
|
| 430 |
+
item = EVALUATIONS.get(evaluation_id)
|
| 431 |
+
if not item:
|
| 432 |
+
self.send_json(404, {"error": "evaluation not found"})
|
| 433 |
+
return
|
| 434 |
+
self.send_json(200, asdict(item))
|
| 435 |
+
return
|
| 436 |
+
if self.path.startswith("/api/jobs/"):
|
| 437 |
+
job_id = self.path.removeprefix("/api/jobs/").split("?", 1)[0]
|
| 438 |
+
job = STORE.get(job_id)
|
| 439 |
+
if not job:
|
| 440 |
+
self.send_json(404, {"error": "job not found"})
|
| 441 |
+
return
|
| 442 |
+
self.send_json(200, asdict(job))
|
| 443 |
+
return
|
| 444 |
+
super().do_GET()
|
| 445 |
+
|
| 446 |
+
def do_POST(self) -> None: # noqa: N802
|
| 447 |
+
try:
|
| 448 |
+
body = self.read_json()
|
| 449 |
+
if self.path == "/api/nim/health":
|
| 450 |
+
result = nim_health(body.get("endpoint", ""), body.get("api_key", ""))
|
| 451 |
+
self.send_json(200, result)
|
| 452 |
+
return
|
| 453 |
+
if self.path == "/api/jobs":
|
| 454 |
+
endpoint = validate_endpoint(body.get("endpoint", ""))
|
| 455 |
+
payload = body.get("payload")
|
| 456 |
+
if not isinstance(payload, dict) or not str(payload.get("prompt", "")).strip():
|
| 457 |
+
raise ValueError("payload.prompt 為必填")
|
| 458 |
+
retries = min(3, max(0, int(body.get("max_retries", 2))))
|
| 459 |
+
timeout = min(1800, max(60, int(body.get("timeout_seconds", 900))))
|
| 460 |
+
job = STORE.create(str(body.get("variant_id", "world")), retries)
|
| 461 |
+
EXECUTOR.submit(run_job, job.id, endpoint, payload, body.get("api_key", ""), timeout)
|
| 462 |
+
self.send_json(202, asdict(job))
|
| 463 |
+
return
|
| 464 |
+
if self.path == "/api/evaluations":
|
| 465 |
+
job = STORE.get(str(body.get("job_id", "")))
|
| 466 |
+
if not job or job.status != "succeeded":
|
| 467 |
+
raise ValueError("只接受已成功生成影片的 job")
|
| 468 |
+
reason_endpoint = validate_endpoint(body.get("reason_endpoint", ""))
|
| 469 |
+
scenario = body.get("scenario", {})
|
| 470 |
+
if not isinstance(scenario, dict):
|
| 471 |
+
raise ValueError("scenario 必須是 object")
|
| 472 |
+
item = EVALUATIONS.create(job.id, job.variant_id)
|
| 473 |
+
timeout = min(900, max(30, int(body.get("timeout_seconds", 300))))
|
| 474 |
+
EXECUTOR.submit(run_evaluation, item.id, reason_endpoint, scenario, body.get("api_key", ""), timeout)
|
| 475 |
+
self.send_json(202, asdict(item))
|
| 476 |
+
return
|
| 477 |
+
if self.path == "/api/isaac/export":
|
| 478 |
+
manifest = body.get("manifest")
|
| 479 |
+
evaluations = body.get("evaluations")
|
| 480 |
+
if not isinstance(manifest, dict) or not isinstance(evaluations, list):
|
| 481 |
+
raise ValueError("manifest 與 evaluations 為必填")
|
| 482 |
+
archive = build_isaac_package(manifest, evaluations)
|
| 483 |
+
self.send_json(201, {"download_url": f"/outputs/isaac/{archive.name}", "filename": archive.name})
|
| 484 |
+
return
|
| 485 |
+
if self.path == "/api/isaac/runs":
|
| 486 |
+
package_url = str(body.get("package_url", ""))
|
| 487 |
+
if not package_url.startswith("/outputs/isaac/") or not package_url.endswith(".zip"):
|
| 488 |
+
raise ValueError("package_url 必須是 Studio 產生的 Isaac ZIP")
|
| 489 |
+
package_path = (ROOT / package_url.lstrip("/")).resolve()
|
| 490 |
+
if not package_path.is_file() or ISAAC_DIR.resolve() not in package_path.parents:
|
| 491 |
+
raise ValueError("Isaac 工作包不存在")
|
| 492 |
+
backend = str(body.get("backend", "isaac"))
|
| 493 |
+
if backend not in {"isaac", "contract"}:
|
| 494 |
+
raise ValueError("backend must be isaac or contract")
|
| 495 |
+
episodes = min(100, max(1, int(body.get("episodes_per_variant", 3))))
|
| 496 |
+
run = ISAAC_RUNS.create(str(package_path), backend, episodes)
|
| 497 |
+
self.send_json(202, asdict(run))
|
| 498 |
+
return
|
| 499 |
+
if self.path.startswith("/api/isaac/runs/") and self.path.endswith("/telemetry"):
|
| 500 |
+
run_id = self.path.removeprefix("/api/isaac/runs/").removesuffix("/telemetry")
|
| 501 |
+
run = ISAAC_RUNS.get(run_id)
|
| 502 |
+
if not run:
|
| 503 |
+
self.send_json(404, {"error": "Isaac run not found"})
|
| 504 |
+
return
|
| 505 |
+
ISAAC_RUNS.update(run_id, status="running", message=f"Episode {body.get('episode', '?')} · {body.get('phase', 'running')}", telemetry=body)
|
| 506 |
+
self.send_json(200, asdict(ISAAC_RUNS.get(run_id)))
|
| 507 |
+
return
|
| 508 |
+
if self.path.startswith("/api/isaac/runs/") and self.path.endswith("/result"):
|
| 509 |
+
run_id = self.path.removeprefix("/api/isaac/runs/").removesuffix("/result")
|
| 510 |
+
run = ISAAC_RUNS.get(run_id)
|
| 511 |
+
if not run:
|
| 512 |
+
self.send_json(404, {"error": "Isaac run not found"})
|
| 513 |
+
return
|
| 514 |
+
required = {"success_rate", "collision_rate", "minimum_clearance_m", "status"}
|
| 515 |
+
if not required.issubset(body):
|
| 516 |
+
raise ValueError("Isaac result 缺少必要指標")
|
| 517 |
+
run_status = "failed" if body.get("status") == "error" else "succeeded"
|
| 518 |
+
ISAAC_RUNS.update(run_id, status=run_status, message="Isaac 驗證完成" if run_status == "succeeded" else "Isaac Scene Adapter 錯誤", result=body)
|
| 519 |
+
self.send_json(200, asdict(ISAAC_RUNS.get(run_id)))
|
| 520 |
+
return
|
| 521 |
+
if self.path.startswith("/api/jobs/") and self.path.endswith("/retry"):
|
| 522 |
+
job_id = self.path.removeprefix("/api/jobs/").removesuffix("/retry")
|
| 523 |
+
old = STORE.get(job_id)
|
| 524 |
+
if not old or old.status != "failed":
|
| 525 |
+
self.send_json(409, {"error": "only failed jobs can be retried"})
|
| 526 |
+
return
|
| 527 |
+
endpoint = validate_endpoint(body.get("endpoint", ""))
|
| 528 |
+
payload = body.get("payload", {})
|
| 529 |
+
job = STORE.create(old.variant_id, min(3, max(0, int(body.get("max_retries", 2)))))
|
| 530 |
+
EXECUTOR.submit(run_job, job.id, endpoint, payload, body.get("api_key", ""), int(body.get("timeout_seconds", 900)))
|
| 531 |
+
self.send_json(202, asdict(job))
|
| 532 |
+
return
|
| 533 |
+
self.send_json(404, {"error": "not found"})
|
| 534 |
+
except HTTPError as exc:
|
| 535 |
+
self.send_json(502, {"error": f"NIM health HTTP {exc.code}"})
|
| 536 |
+
except (URLError, TimeoutError) as exc:
|
| 537 |
+
self.send_json(502, {"error": f"無法連線 NIM:{exc}"})
|
| 538 |
+
except (ValueError, TypeError, json.JSONDecodeError) as exc:
|
| 539 |
+
self.send_json(400, {"error": str(exc)})
|
| 540 |
+
except Exception as exc:
|
| 541 |
+
self.send_json(500, {"error": str(exc)})
|
| 542 |
+
|
| 543 |
+
|
| 544 |
+
def main() -> None:
|
| 545 |
+
parser = argparse.ArgumentParser(description="Cosmos Scenario Studio server")
|
| 546 |
+
parser.add_argument("--host", default=os.environ.get("COSMOS_STUDIO_HOST", "127.0.0.1"))
|
| 547 |
+
parser.add_argument("--port", type=int, default=int(os.environ.get("COSMOS_STUDIO_PORT", "8090")))
|
| 548 |
+
args = parser.parse_args()
|
| 549 |
+
server = ThreadingHTTPServer((args.host, args.port), Handler)
|
| 550 |
+
print(f"Cosmos Scenario Studio: http://{args.host}:{args.port}")
|
| 551 |
+
try:
|
| 552 |
+
server.serve_forever()
|
| 553 |
+
except KeyboardInterrupt:
|
| 554 |
+
pass
|
| 555 |
+
finally:
|
| 556 |
+
server.server_close()
|
| 557 |
+
EXECUTOR.shutdown(wait=False, cancel_futures=True)
|
| 558 |
+
|
| 559 |
+
|
| 560 |
+
if __name__ == "__main__":
|
| 561 |
+
main()
|
styles.css
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
--ink: #f1f4ed;
|
| 3 |
+
--muted: #929a8f;
|
| 4 |
+
--bg: #070a08;
|
| 5 |
+
--panel: #0d110e;
|
| 6 |
+
--line: #252b26;
|
| 7 |
+
--green: #76b900;
|
| 8 |
+
--acid: #b7ff38;
|
| 9 |
+
--warn: #ffb548;
|
| 10 |
+
}
|
| 11 |
+
|
| 12 |
+
* { box-sizing: border-box; }
|
| 13 |
+
html { scroll-behavior: smooth; }
|
| 14 |
+
body { margin: 0; background: var(--bg); color: var(--ink); font-family: Inter, ui-sans-serif, system-ui, -apple-system, sans-serif; }
|
| 15 |
+
button, input, textarea, select { font: inherit; }
|
| 16 |
+
button { color: inherit; }
|
| 17 |
+
.topbar { height: 72px; padding: 0 4vw; border-bottom: 1px solid var(--line); display: flex; align-items: center; justify-content: space-between; position: sticky; top: 0; z-index: 10; background: rgba(7,10,8,.9); backdrop-filter: blur(14px); }
|
| 18 |
+
.brand { display: flex; gap: 12px; align-items: center; color: var(--ink); text-decoration: none; font-size: 12px; font-weight: 800; line-height: 1.05; letter-spacing: .14em; }
|
| 19 |
+
.brand small { color: var(--muted); font-size: 8px; }
|
| 20 |
+
.brand-mark { width: 34px; height: 34px; background: var(--green); color: #081000; display: grid; place-items: center; font-size: 20px; font-weight: 900; transform: skew(-8deg); }
|
| 21 |
+
nav { display: flex; gap: 30px; }
|
| 22 |
+
nav a { color: var(--muted); text-decoration: none; font-size: 12px; }
|
| 23 |
+
nav a:hover { color: var(--acid); }
|
| 24 |
+
.system-status { color: var(--muted); font: 10px ui-monospace, monospace; display: flex; align-items: center; gap: 8px; }
|
| 25 |
+
.system-status i { width: 7px; height: 7px; border-radius: 50%; background: var(--green); box-shadow: 0 0 12px var(--green); }
|
| 26 |
+
main { width: min(1600px, 94vw); margin: auto; }
|
| 27 |
+
.hero { min-height: 340px; padding: 70px 1vw 55px; display: grid; grid-template-columns: 1.5fr .8fr; gap: 8vw; align-items: end; }
|
| 28 |
+
.eyebrow, .step { color: var(--green); font: 600 10px ui-monospace, monospace; letter-spacing: .18em; }
|
| 29 |
+
h1 { font-size: clamp(50px, 6.4vw, 100px); line-height: .88; letter-spacing: -.065em; margin: 18px 0 0; }
|
| 30 |
+
h1 em { font-style: normal; color: transparent; -webkit-text-stroke: 1px #838b80; }
|
| 31 |
+
.hero-copy { color: #b8bfb5; line-height: 1.8; font-size: 15px; max-width: 520px; border-left: 1px solid var(--green); padding-left: 25px; }
|
| 32 |
+
.release-strip { display: grid; grid-template-columns: repeat(4,1fr); border: 1px solid var(--line); margin: 0 0 24px; background: var(--panel); }
|
| 33 |
+
.release-strip article { min-height: 112px; padding: 20px; border-right: 1px solid var(--line); }
|
| 34 |
+
.release-strip article:last-child { border-right: 0; }
|
| 35 |
+
.release-strip span { display: block; color: var(--green); font: 9px ui-monospace, monospace; letter-spacing: .12em; }
|
| 36 |
+
.release-strip strong { display: block; margin: 12px 0 7px; font-size: 15px; }
|
| 37 |
+
.release-strip small { color: var(--muted); font-size: 10px; line-height: 1.5; }
|
| 38 |
+
.release-strip .boundary { background: rgba(255,181,72,.045); }
|
| 39 |
+
.release-strip .boundary span { color: var(--warn); }
|
| 40 |
+
.workspace { display: grid; grid-template-columns: minmax(280px, .9fr) minmax(430px, 1.6fr) minmax(280px, .85fr); border: 1px solid var(--line); }
|
| 41 |
+
.panel { background: var(--panel); min-width: 0; }
|
| 42 |
+
.panel + .panel { border-left: 1px solid var(--line); }
|
| 43 |
+
.composer, .dataset { padding: 24px; }
|
| 44 |
+
.world { padding: 24px; background: #0a0d0b; }
|
| 45 |
+
.panel-head { min-height: 55px; display: flex; justify-content: space-between; align-items: flex-start; gap: 15px; margin-bottom: 20px; }
|
| 46 |
+
.panel-head p { margin: 0 0 7px; }
|
| 47 |
+
h2 { margin: 0; font-size: 22px; letter-spacing: -.035em; }
|
| 48 |
+
.icon-button { border: 1px solid var(--line); background: transparent; width: 36px; height: 36px; cursor: pointer; }
|
| 49 |
+
.presets, .mode-tabs { display: grid; grid-template-columns: repeat(3, 1fr); border: 1px solid var(--line); margin-bottom: 20px; }
|
| 50 |
+
.presets button, .mode-tabs button { border: 0; border-right: 1px solid var(--line); padding: 10px 5px; background: #090c0a; color: var(--muted); cursor: pointer; font-size: 11px; }
|
| 51 |
+
.presets button:last-child, .mode-tabs button:last-child { border: 0; }
|
| 52 |
+
.presets button.active, .mode-tabs button.active { background: var(--green); color: #071000; font-weight: 800; }
|
| 53 |
+
form label, .nim-config label { display: block; color: #aab1a7; font-size: 10px; text-transform: uppercase; letter-spacing: .08em; margin-bottom: 12px; }
|
| 54 |
+
input, textarea, select { width: 100%; margin-top: 6px; border: 1px solid var(--line); background: #080b09; color: var(--ink); padding: 10px 11px; outline: none; resize: vertical; border-radius: 0; }
|
| 55 |
+
input:focus, textarea:focus, select:focus { border-color: var(--green); box-shadow: 0 0 0 1px rgba(118,185,0,.2); }
|
| 56 |
+
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 10px; }
|
| 57 |
+
.primary, .secondary { width: 100%; min-height: 44px; border: 0; cursor: pointer; font-weight: 800; font-size: 11px; letter-spacing: .08em; }
|
| 58 |
+
.primary { background: var(--green); color: #071000; display: flex; justify-content: center; align-items: center; gap: 20px; }
|
| 59 |
+
.primary b { font: 10px ui-monospace, monospace; opacity: .6; }
|
| 60 |
+
.primary:disabled { opacity: .28; cursor: not-allowed; }
|
| 61 |
+
.secondary { background: transparent; border: 1px solid var(--line); margin-top: 8px; }
|
| 62 |
+
.form-error { color: #ffbd6e; background: rgba(255,181,72,.08); border-left: 2px solid var(--warn); padding: 10px; font-size: 11px; margin-bottom: 12px; line-height: 1.5; }
|
| 63 |
+
.model-pill, #queue-count { font: 9px ui-monospace, monospace; padding: 6px 8px; border: 1px solid #33402e; color: var(--acid); white-space: nowrap; }
|
| 64 |
+
.viewport { height: 450px; position: relative; overflow: hidden; background: radial-gradient(circle at 60% 25%, #445329 0, #182011 20%, #080b08 65%); border: 1px solid #283124; perspective: 800px; }
|
| 65 |
+
.scanlines { position: absolute; inset: 0; z-index: 4; pointer-events: none; opacity: .13; background: repeating-linear-gradient(0deg, transparent 0 3px, #b6ff7f 4px); }
|
| 66 |
+
.warehouse-scene { position: absolute; inset: 0; overflow: hidden; }
|
| 67 |
+
.warehouse-scene:after { content: ""; position: absolute; left: 18%; right: 18%; bottom: -15%; height: 65%; background: linear-gradient(120deg, #232a20, #0e120e); transform: perspective(300px) rotateX(35deg); border: 1px solid #3e4938; }
|
| 68 |
+
.shelf { position: absolute; width: 24%; height: 72%; top: 15%; z-index: 2; background: repeating-linear-gradient(0deg, #151b14 0 18%, #5f6d56 19% 20%), repeating-linear-gradient(90deg, transparent 0 28%, #4b5644 29% 32%); border: 2px solid #5b6753; box-shadow: 0 15px 40px #000; }
|
| 69 |
+
.shelf:after { content: ""; position: absolute; inset: 5% 10%; background: repeating-linear-gradient(0deg, transparent 0 21%, rgba(118,185,0,.35) 22% 26%); }
|
| 70 |
+
.shelf-a { left: -3%; transform: skewY(3deg); }
|
| 71 |
+
.shelf-b { right: -3%; transform: skewY(-3deg); }
|
| 72 |
+
.floor-line { position: absolute; bottom: -10%; width: 2px; height: 65%; background: var(--green); opacity: .45; z-index: 3; transform-origin: bottom; }
|
| 73 |
+
.floor-line.one { left: 38%; transform: rotate(13deg); }.floor-line.two { right: 38%; transform: rotate(-13deg); }
|
| 74 |
+
.robot { width: 74px; height: 58px; position: absolute; bottom: 18%; left: 43%; z-index: 5; background: #9bad86; border: 3px solid #dce8d2; clip-path: polygon(10% 0,85% 0,100% 70%,80% 100%,10% 100%,0 65%); box-shadow: 0 0 28px rgba(183,255,56,.2); }
|
| 75 |
+
.robot i { position: absolute; width: 26px; height: 12px; background: #111; left: 24px; top: 9px; border: 2px solid var(--green); }
|
| 76 |
+
.robot span { position: absolute; width: 6px; height: 34px; background: #708060; left: 35px; bottom: 100%; }
|
| 77 |
+
.human { position: absolute; right: 30%; bottom: 25%; z-index: 5; width: 18px; height: 55px; background: #bd6c3c; clip-path: polygon(30% 0,70% 0,100% 45%,75% 100%,25% 100%,0 45%); }
|
| 78 |
+
.human i { position: absolute; width: 15px; height: 15px; border-radius: 50%; background: #c4a189; top: -14px; left: 2px; }
|
| 79 |
+
.hazard-zone { position: absolute; width: 110px; height: 60px; border: 1px solid var(--warn); right: 24%; bottom: 17%; z-index: 4; transform: skewX(-20deg); animation: pulse 2s infinite; }
|
| 80 |
+
.view-hud { position: absolute; left: 15px; right: 15px; z-index: 6; display: flex; justify-content: space-between; font: 9px ui-monospace, monospace; color: #d6dfcf; }
|
| 81 |
+
.hud-top { top: 14px; }.hud-bottom { bottom: 12px; }.hud-bottom i { display: inline-block; width: 6px; height: 6px; background: var(--green); border-radius: 50%; margin-right: 5px; }
|
| 82 |
+
.play { position: absolute; z-index: 7; left: 50%; top: 50%; transform: translate(-50%,-50%); width: 58px; height: 58px; border-radius: 50%; border: 1px solid rgba(255,255,255,.5); background: rgba(5,8,6,.62); backdrop-filter: blur(8px); cursor: pointer; }
|
| 83 |
+
.playing .robot { animation: robotMove 3s ease-in-out infinite alternate; }
|
| 84 |
+
.playing .human { animation: humanMove 2.4s ease-in-out infinite alternate; }
|
| 85 |
+
.risk-strip { display: grid; grid-template-columns: 90px 1fr auto; gap: 15px; align-items: center; margin: 18px 0; }
|
| 86 |
+
.risk-strip span { color: var(--muted); font-size: 9px; display: block; }.risk-strip strong { font-size: 24px; }
|
| 87 |
+
.risk-bar { height: 4px; background: #222a21; }.risk-bar i { display: block; height: 100%; background: linear-gradient(90deg,var(--green),var(--warn)); transition: width .4s; }
|
| 88 |
+
.risk-strip p { color: var(--muted); font: 9px ui-monospace, monospace; }
|
| 89 |
+
.prompt-block { border: 1px solid var(--line); background: #080b09; }
|
| 90 |
+
.prompt-head { border-bottom: 1px solid var(--line); padding: 8px 12px; display: flex; justify-content: space-between; color: var(--green); font: 9px ui-monospace, monospace; }
|
| 91 |
+
.prompt-head button { border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: 9px; }
|
| 92 |
+
.prompt-block p { margin: 0; padding: 14px; color: #aab2a7; font: 10px/1.7 ui-monospace, monospace; max-height: 105px; overflow: auto; }
|
| 93 |
+
.mode-tabs { grid-template-columns: 1fr 1fr; }
|
| 94 |
+
.nim-config { padding: 12px; border: 1px solid var(--line); margin-bottom: 12px; }
|
| 95 |
+
.nim-health { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; font: 9px ui-monospace, monospace; }
|
| 96 |
+
.nim-health span { display: flex; align-items: center; gap: 7px; }.nim-health i { width: 7px; height: 7px; background: #596157; border-radius: 50%; display: inline-block; }.nim-health i.ready { background: var(--green); box-shadow: 0 0 8px var(--green); }.nim-health i.failed { background: #ff675f; }
|
| 97 |
+
.nim-health button { border: 1px solid var(--line); background: transparent; color: var(--muted); padding: 6px 9px; cursor: pointer; font-size: 9px; }
|
| 98 |
+
.retry-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
|
| 99 |
+
.empty-queue { height: 300px; display: grid; place-content: center; text-align: center; color: var(--muted); }
|
| 100 |
+
.empty-queue span { color: #394136; font-size: 54px; }.empty-queue p { margin: 8px 0 3px; color: #c4cbc1; }.empty-queue small { font-size: 10px; }
|
| 101 |
+
.queue { height: 300px; overflow: auto; border-top: 1px solid var(--line); }
|
| 102 |
+
.queue-item { display: grid; grid-template-columns: 30px 1fr auto; align-items: center; gap: 8px; border-bottom: 1px solid var(--line); padding: 12px 5px; }
|
| 103 |
+
.queue-index { color: #596157; font: 10px ui-monospace, monospace; }.queue-item strong { display: block; font-size: 11px; }.queue-item small { color: var(--muted); font: 8px ui-monospace, monospace; }.queue-item b { font: 8px ui-monospace, monospace; color: var(--muted); }
|
| 104 |
+
.queue-item.running b { color: var(--warn); }.queue-item.complete b, .queue-item.succeeded b { color: var(--green); }.queue-item.failed b { color: #ff675f; }
|
| 105 |
+
.queue-item.retrying b { color: var(--warn); }.queue-item { cursor: default; }.queue-item.complete, .queue-item.succeeded, .queue-item.failed { cursor: pointer; }.queue-item em { display: block; color: #6e766c; font: 8px ui-monospace, monospace; font-style: normal; margin-top: 3px; }
|
| 106 |
+
.result-player { border: 1px solid var(--line); margin-top: 14px; background: #050705; }.result-head { display: flex; justify-content: space-between; padding: 8px 10px; border-bottom: 1px solid var(--line); font: 8px ui-monospace, monospace; color: var(--green); }.result-head a { color: var(--muted); text-decoration: none; }.result-player video { width: 100%; display: block; max-height: 230px; background: #000; }
|
| 107 |
+
.dataset-actions { margin-top: 18px; }
|
| 108 |
+
.privacy-note { color: #60675f; font-size: 9px; line-height: 1.5; }
|
| 109 |
+
.architecture { padding: 90px 1vw; }
|
| 110 |
+
.analysis-lab { margin-top: 40px; border: 1px solid var(--line); display: grid; grid-template-columns: .75fr 1.6fr; background: var(--panel); }
|
| 111 |
+
.analysis-copy { padding: 34px; border-right: 1px solid var(--line); }.analysis-copy h2 { font-size: clamp(28px,3vw,46px); line-height: 1.05; max-width: 520px; }.analysis-copy > p:last-child { color: var(--muted); line-height: 1.7; font-size: 12px; max-width: 480px; }
|
| 112 |
+
.reason-panel { padding: 24px; }.reason-config { display: grid; grid-template-columns: 1fr 1fr auto; gap: 12px; align-items: end; }.reason-config label { color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .08em; }.reason-config input { margin-top: 6px; }.reason-config .auto-evaluate { display: flex; align-items: center; gap: 8px; height: 42px; white-space: nowrap; }.auto-evaluate input { width: auto; margin: 0; accent-color: var(--green); }
|
| 113 |
+
.reason-actions { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 16px 0; }.reason-actions .secondary { margin: 0; }
|
| 114 |
+
.isaac-runner { display: grid; grid-template-columns: 1fr 1fr 1.2fr; gap: 10px; align-items: end; margin: 16px 0; padding: 14px; border: 1px solid var(--line); }.isaac-runner label { color: var(--muted); font-size: 9px; text-transform: uppercase; letter-spacing: .08em; }.isaac-runner .secondary { margin: 0; }
|
| 115 |
+
.isaac-result { display: grid; grid-template-columns: repeat(4,1fr); gap: 1px; background: var(--line); border: 1px solid var(--line); margin-bottom: 16px; }.isaac-result article { background: #090c0a; padding: 12px; }.isaac-result span { color: var(--muted); display: block; font: 8px ui-monospace, monospace; }.isaac-result strong { display: block; margin-top: 6px; font-size: 18px; }.isaac-result .passed strong { color: var(--green); }.isaac-result .failed strong { color: #ff675f; }
|
| 116 |
+
.ranking-empty { border: 1px dashed #30362f; color: #697066; min-height: 170px; display: grid; place-items: center; text-align: center; font-size: 11px; }
|
| 117 |
+
.ranking { border-top: 1px solid var(--line); }.rank-row { display: grid; grid-template-columns: 36px 1fr 66px; gap: 12px; padding: 14px 5px; border-bottom: 1px solid var(--line); align-items: center; }.rank-row > b { color: var(--green); font: 12px ui-monospace, monospace; }.rank-row h3 { margin: 0 0 7px; font-size: 13px; }.score-grid { display: grid; grid-template-columns: repeat(4, minmax(70px,1fr)); gap: 7px; }.score-grid span { color: var(--muted); font: 8px ui-monospace, monospace; }.score-grid strong { color: var(--ink); display: block; font-size: 13px; margin-top: 2px; }.rank-reason { color: #777f75; font-size: 9px; line-height: 1.5; margin: 8px 0 0; }.priority { width: 58px; height: 58px; border-radius: 50%; border: 1px solid var(--green); display: grid; place-items: center; text-align: center; color: var(--acid); font: 17px ui-monospace, monospace; }.priority small { display: block; font-size: 6px; color: var(--muted); }
|
| 118 |
+
.flow { display: grid; grid-template-columns: 1fr auto 1fr auto 1fr auto 1fr; gap: 20px; align-items: center; margin-top: 25px; }
|
| 119 |
+
.flow article { border-top: 1px solid var(--line); padding-top: 18px; }.flow b { color: var(--green); font: 10px ui-monospace, monospace; display: block; margin-bottom: 25px; }.flow span { font-size: 17px; display: block; }.flow small { color: var(--muted); }.flow > i { color: #465043; }
|
| 120 |
+
footer { border-top: 1px solid var(--line); padding: 25px 4vw; display: flex; justify-content: space-between; color: #697066; font: 9px ui-monospace, monospace; }
|
| 121 |
+
@keyframes pulse { 50% { opacity: .3; } }
|
| 122 |
+
@keyframes robotMove { to { transform: translate(80px,-25px) scale(.85); } }
|
| 123 |
+
@keyframes humanMove { to { transform: translate(-55px,8px); } }
|
| 124 |
+
@media (max-width: 1100px) {
|
| 125 |
+
.release-strip { grid-template-columns: 1fr 1fr; }.release-strip article:nth-child(2) { border-right: 0; }.release-strip article:nth-child(-n+2) { border-bottom: 1px solid var(--line); }.workspace { grid-template-columns: 1fr 1.5fr; }.dataset { grid-column: 1 / -1; border-left: 0 !important; border-top: 1px solid var(--line); }.queue, .empty-queue { height: 220px; }
|
| 126 |
+
}
|
| 127 |
+
@media (max-width: 720px) {
|
| 128 |
+
nav { display: none; }.system-status span { display: none; }.hero { grid-template-columns: 1fr; min-height: 420px; }.release-strip { grid-template-columns: 1fr; }.release-strip article { border-right: 0; border-bottom: 1px solid var(--line); }.release-strip article:last-child { border-bottom: 0; }.workspace { display: block; }.panel + .panel { border-left: 0; border-top: 1px solid var(--line); }.viewport { height: 390px; }.risk-strip { grid-template-columns: 80px 1fr; }.risk-strip p { grid-column: 1 / -1; margin: 0; }.analysis-lab { display: block; }.analysis-copy { border-right: 0; border-bottom: 1px solid var(--line); }.reason-config, .isaac-runner { grid-template-columns: 1fr; }.isaac-result { grid-template-columns: 1fr 1fr; }.score-grid { grid-template-columns: 1fr 1fr; }.flow { grid-template-columns: 1fr; }.flow > i { transform: rotate(90deg); justify-self: center; }.architecture { padding: 60px 2vw; } footer { gap: 20px; }
|
| 129 |
+
}
|
test_server.py
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import json
|
| 3 |
+
import tempfile
|
| 4 |
+
import unittest
|
| 5 |
+
import zipfile
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
from unittest.mock import patch
|
| 8 |
+
|
| 9 |
+
import server
|
| 10 |
+
from isaac_adapter.contracts import EpisodeResult, aggregate_results, sample_episode, validate_mapping
|
| 11 |
+
from isaac_adapter.calibration import build_calibration_report
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class ServerTests(unittest.TestCase):
|
| 15 |
+
def setUp(self):
|
| 16 |
+
server.STORE = server.JobStore()
|
| 17 |
+
server.EVALUATIONS = server.EvaluationStore()
|
| 18 |
+
server.ISAAC_RUNS = server.IsaacRunStore()
|
| 19 |
+
|
| 20 |
+
def test_validate_endpoint(self):
|
| 21 |
+
self.assertEqual(server.validate_endpoint("http://127.0.0.1:8000/v1/infer"), "http://127.0.0.1:8000/v1/infer")
|
| 22 |
+
with self.assertRaises(ValueError):
|
| 23 |
+
server.validate_endpoint("file:///tmp/video")
|
| 24 |
+
|
| 25 |
+
def test_job_store_lifecycle(self):
|
| 26 |
+
job = server.STORE.create("world-01", 2)
|
| 27 |
+
server.STORE.update(job.id, status="running", attempts=1)
|
| 28 |
+
self.assertEqual(server.STORE.get(job.id).status, "running")
|
| 29 |
+
self.assertEqual(server.STORE.get(job.id).attempts, 1)
|
| 30 |
+
|
| 31 |
+
def test_run_job_saves_video(self):
|
| 32 |
+
job = server.STORE.create("world-01", 0)
|
| 33 |
+
with tempfile.TemporaryDirectory() as temp, patch.object(server, "OUTPUT_DIR", Path(temp)), patch.object(
|
| 34 |
+
server, "request_inference", return_value=b"valid-mp4-bytes"
|
| 35 |
+
):
|
| 36 |
+
server.run_job(job.id, "http://nim/v1/infer", {"prompt": "robot"}, "", 60)
|
| 37 |
+
result = server.STORE.get(job.id)
|
| 38 |
+
self.assertEqual(result.status, "succeeded")
|
| 39 |
+
self.assertEqual((Path(temp) / f"{job.id}.mp4").read_bytes(), b"valid-mp4-bytes")
|
| 40 |
+
|
| 41 |
+
def test_non_retryable_failure_stops(self):
|
| 42 |
+
job = server.STORE.create("world-01", 2)
|
| 43 |
+
with patch.object(server, "request_inference", side_effect=RuntimeError("bad response")) as request:
|
| 44 |
+
server.run_job(job.id, "http://nim/v1/infer", {"prompt": "robot"}, "", 60)
|
| 45 |
+
self.assertEqual(server.STORE.get(job.id).status, "failed")
|
| 46 |
+
self.assertEqual(request.call_count, 1)
|
| 47 |
+
|
| 48 |
+
def test_extract_and_normalize_reason_scores(self):
|
| 49 |
+
parsed = server.extract_json('<think>hidden</think>```json\n{"collision_risk":90,"physics_plausibility":70,"task_success":20,"danger_severity":80}\n```')
|
| 50 |
+
scores = server.normalize_scores(parsed)
|
| 51 |
+
self.assertEqual(scores["priority"], 73)
|
| 52 |
+
self.assertEqual(scores["collision_risk"], 90)
|
| 53 |
+
|
| 54 |
+
def test_isaac_package_contains_contracts(self):
|
| 55 |
+
manifest = {"variants": [{"id": "world-01", "seed": 7, "request": {"prompt": "robot"}}]}
|
| 56 |
+
evaluations = [{"variant_id": "world-01", "scores": {"priority": 75, "recommended_test": "clearance"}}]
|
| 57 |
+
with tempfile.TemporaryDirectory() as temp, patch.object(server, "ISAAC_DIR", Path(temp)):
|
| 58 |
+
archive = server.build_isaac_package(manifest, evaluations)
|
| 59 |
+
with zipfile.ZipFile(archive) as bundle:
|
| 60 |
+
self.assertEqual(set(bundle.namelist()), {"cosmos_manifest.json", "domain_randomization.json", "policy_validation.json", "scene_mapping.example.json"})
|
| 61 |
+
validation = json.loads(bundle.read("policy_validation.json"))
|
| 62 |
+
self.assertEqual(validation["ranked_edge_cases"][0]["priority"], 75)
|
| 63 |
+
|
| 64 |
+
def test_scene_mapping_and_episode_aggregation(self):
|
| 65 |
+
validate_mapping({"prims": {"robot": "/World/R", "task_object": "/World/O", "task_target": "/World/T", "hazard": "/World/H", "camera": "/World/C"}})
|
| 66 |
+
randomization = {
|
| 67 |
+
"lighting": {"intensity_lux": [300, 900], "color_temperature_k": [3500, 7000]},
|
| 68 |
+
"camera": {"translation_noise_m": [-0.01, 0.01], "rotation_noise_deg": [-2, 2]},
|
| 69 |
+
"object": {"xy_position_noise_m": [-0.02, 0.02], "yaw_noise_deg": [-10, 10]},
|
| 70 |
+
"physics": {"friction": [0.4, 1.0], "payload_mass_kg": [0.02, 0.1], "joint_damping_scale": [0.8, 1.2]},
|
| 71 |
+
}
|
| 72 |
+
self.assertEqual(sample_episode(randomization, 7), sample_episode(randomization, 7))
|
| 73 |
+
result = aggregate_results([EpisodeResult(1, "world-01", True, False, 0.2, 0.02, 1.0, 7)], {"minimum_success_rate": 90, "maximum_collision_rate": 0, "minimum_clearance_m": 0.15})
|
| 74 |
+
self.assertEqual(result["status"], "passed")
|
| 75 |
+
|
| 76 |
+
def test_warehouse_mapping_covers_full_franka_chain(self):
|
| 77 |
+
mapping = json.loads((Path(__file__).parent / "scene_mapping.warehouse.json").read_text())
|
| 78 |
+
validate_mapping(mapping)
|
| 79 |
+
self.assertEqual(len(mapping["prims"]["collision_parents"]), 9)
|
| 80 |
+
self.assertIn("/World/Franka/panda_hand", mapping["prims"]["collision_parents"])
|
| 81 |
+
|
| 82 |
+
def test_calibration_report_preserves_safety_policy(self):
|
| 83 |
+
metrics = aggregate_results(
|
| 84 |
+
[EpisodeResult(1, "world-01", False, False, 0.2, 0.08, 4.0, 7, False, 4000)],
|
| 85 |
+
{"minimum_success_rate": 90, "maximum_collision_rate": 0, "minimum_clearance_m": 0.15},
|
| 86 |
+
)
|
| 87 |
+
report = build_calibration_report(metrics, {"end_effector_offset": [0, 0, 0.1], "success_tolerance_m": 0.045})
|
| 88 |
+
self.assertEqual(report["findings"][0]["code"], "controller_timeout")
|
| 89 |
+
self.assertEqual(len(report["mapping_candidates"]), 3)
|
| 90 |
+
self.assertFalse(report["safety_policy_changed"])
|
| 91 |
+
|
| 92 |
+
def test_isaac_run_claim_is_atomic(self):
|
| 93 |
+
run = server.ISAAC_RUNS.create("/tmp/package.zip", "contract", 3)
|
| 94 |
+
claimed = server.ISAAC_RUNS.next_queued()
|
| 95 |
+
self.assertEqual(claimed.id, run.id)
|
| 96 |
+
self.assertEqual(claimed.status, "running")
|
| 97 |
+
self.assertIsNone(server.ISAAC_RUNS.next_queued())
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
if __name__ == "__main__":
|
| 101 |
+
unittest.main()
|