junbin666 commited on
Commit
a3cddc4
·
verified ·
1 Parent(s): e034f3f

Publish Cosmos Scenario Studio static portfolio demo

Browse files
.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: gray
5
- colorTo: blue
6
  sdk: static
 
7
  pinned: false
 
 
 
 
 
 
 
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", "'": "&#39;", '"': "&quot;" }[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
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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()