Spaces:
Running
Running
jasondo OpenAI Codex commited on
Commit ·
51992f4
1
Parent(s): e91ca3c
Use real Modal analysis path
Browse filesCo-authored-by: OpenAI Codex <codex@openai.com>
- .env.example +2 -1
- AGENTS.md +44 -0
- README.md +30 -19
- index.html +296 -6
- modal_app.py +77 -7
- snap2sim/backend.py +7 -5
- snap2sim/model_io.py +113 -17
- snap2sim/prompts.py +21 -9
- snap2sim/schema.py +121 -23
.env.example
CHANGED
|
@@ -4,6 +4,7 @@
|
|
| 4 |
|
| 5 |
INFERENCE_BACKEND=modal
|
| 6 |
MODAL_ANALYZE_URL=
|
|
|
|
| 7 |
MODAL_GENERATE_URL=
|
| 8 |
-
INFERENCE_TIMEOUT_SECONDS=
|
| 9 |
SNAP2SIM_API_TOKEN=
|
|
|
|
| 4 |
|
| 5 |
INFERENCE_BACKEND=modal
|
| 6 |
MODAL_ANALYZE_URL=
|
| 7 |
+
# Optional compatibility value; scene descriptors are generated locally.
|
| 8 |
MODAL_GENERATE_URL=
|
| 9 |
+
INFERENCE_TIMEOUT_SECONDS=300
|
| 10 |
SNAP2SIM_API_TOKEN=
|
AGENTS.md
CHANGED
|
@@ -209,9 +209,53 @@ technical cutaway animation.
|
|
| 209 |
`/analyze_image` returned a validated mechanism payload through the secured
|
| 210 |
Modal bearer-token flow, and `/generate_scene` returned
|
| 211 |
`{ "renderer": "three", "analysis": ... }` with no HTML field.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 212 |
|
| 213 |
## Next Work
|
| 214 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
- Optional polish only after private deployment verification: component
|
| 216 |
watermark, noise texture, vignette, then scan-line reveal refinements.
|
| 217 |
- Keep the Hugging Face Space private until the user explicitly approves making
|
|
|
|
| 209 |
`/analyze_image` returned a validated mechanism payload through the secured
|
| 210 |
Modal bearer-token flow, and `/generate_scene` returned
|
| 211 |
`{ "renderer": "three", "analysis": ... }` with no HTML field.
|
| 212 |
+
- Implemented the `REVIEW2.md` local code pass on June 14, 2026: fixed the
|
| 213 |
+
label-overlay pointer-events bug that blocked OrbitControls, added bounded
|
| 214 |
+
camera controls plus Reset view and orbit/zoom hint, changed the source-photo
|
| 215 |
+
preview to a stable contained image box, added optional normalized per-part
|
| 216 |
+
`annotation` fields, added `render_mode` scene descriptors, and added a
|
| 217 |
+
browser-side annotated-photo fallback plus an honest unavailable state.
|
| 218 |
+
- Updated model prompting/parsing for longer reasoning: prompts may allow
|
| 219 |
+
reasoning before the final JSON, parser strips `<think>` blocks and prefers
|
| 220 |
+
the last complete JSON object, and llama.cpp analysis now uses larger
|
| 221 |
+
token/context/timeout budgets (`4096` output tokens, `8192` context, `300s`
|
| 222 |
+
timeout at the analysis call site).
|
| 223 |
+
- Local verification after the `REVIEW2.md` pass: schema/parser checks passed,
|
| 224 |
+
FastAPI `TestClient` confirmed `/`, `/analyze_image`, and `/generate_scene`,
|
| 225 |
+
and browser verification confirmed left-drag orbit, right-drag pan, wheel
|
| 226 |
+
zoom, mobile canvas event targeting, contained source preview, annotated-photo
|
| 227 |
+
fallback, and unavailable retry state.
|
| 228 |
+
- Modal reasoning-budget follow-up on June 14, 2026: an initial
|
| 229 |
+
`run_analysis_endpoint_check` returned a generic fallback, so a local-only
|
| 230 |
+
`run_analysis_raw_check` diagnostic was added. The raw diagnostic showed the
|
| 231 |
+
model used schema-adjacent fields (`radius`, `height`, string axes). The
|
| 232 |
+
prompt now explicitly requires `size` vectors and numeric axis vectors, and
|
| 233 |
+
coercion preserves common radius/height/length/axis-string mistakes instead
|
| 234 |
+
of accepting nested JSON fragments as generic fallbacks. After the fix,
|
| 235 |
+
`run_analysis_raw_check` parsed strict JSON successfully in `35.12s`, and
|
| 236 |
+
`run_analysis_endpoint_check` returned a parsed per-image `targeting reticle`
|
| 237 |
+
payload at `0.7` confidence.
|
| 238 |
+
- User decision after the `REVIEW2.md` pass: use the real Modal
|
| 239 |
+
`analyze_image_llamacpp` analysis path for the demo. `INFERENCE_BACKEND` now
|
| 240 |
+
defaults to `modal`; `INFERENCE_BACKEND=local` is an explicit UI-development
|
| 241 |
+
sample mode only and should not be used for public submission.
|
| 242 |
+
- Deployed the updated Modal app after the real-analysis decision on June 14,
|
| 243 |
+
2026. The stable deployment exposes `analyze_image_llamacpp` with the latest
|
| 244 |
+
prompt/parsing/coercion fixes. A lightweight deployed `runtime_probe` request
|
| 245 |
+
without credentials returned `401 Unauthorized`. Authenticated deployed web
|
| 246 |
+
verification could not be run from the local shell because
|
| 247 |
+
`SNAP2SIM_API_TOKEN` was not set locally; use the Hugging Face Space secret or
|
| 248 |
+
a local secret store for the normal authenticated check.
|
| 249 |
|
| 250 |
## Next Work
|
| 251 |
|
| 252 |
+
- Deploy/sync the latest GitHub changes through the normal GitHub-to-Hugging
|
| 253 |
+
Face workflow, then re-run authenticated private Space verification. Do not
|
| 254 |
+
edit files directly on the Hugging Face Space.
|
| 255 |
+
- Measure the larger llama.cpp reasoning budget with representative real photos;
|
| 256 |
+
tune token/context/timeout values down if valid JSON remains reliable with
|
| 257 |
+
lower latency. The synthetic Modal check now succeeds with the current
|
| 258 |
+
`4096` output token, `8192` context, `300s` timeout settings.
|
| 259 |
- Optional polish only after private deployment verification: component
|
| 260 |
watermark, noise texture, vignette, then scan-line reveal refinements.
|
| 261 |
- Keep the Hugging Face Space private until the user explicitly approves making
|
README.md
CHANGED
|
@@ -18,9 +18,10 @@ structured mechanism analysis, and renders an animated technical cutaway
|
|
| 18 |
visualization.
|
| 19 |
|
| 20 |
The local app serves a trusted `index.html` shell through `gradio.Server` and
|
| 21 |
-
exposes `/analyze_image` plus a compatibility `/generate_scene` route.
|
| 22 |
-
|
| 23 |
-
|
|
|
|
| 24 |
|
| 25 |
## Run Locally
|
| 26 |
|
|
@@ -29,9 +30,14 @@ pip install -r requirements.txt
|
|
| 29 |
python app.py
|
| 30 |
```
|
| 31 |
|
| 32 |
-
Set `INFERENCE_BACKEND=modal`, `MODAL_ANALYZE_URL`, and `
|
| 33 |
-
to point the Gradio app at deployed Modal `
|
| 34 |
-
`/generate_scene`
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 35 |
|
| 36 |
For the deployed demo configuration, copy `.env.example` into local environment
|
| 37 |
variables or Hugging Face Space variables. Do not put tokens in `.env.example`;
|
|
@@ -117,10 +123,13 @@ Runtime flow:
|
|
| 117 |
|
| 118 |
1. Browser encodes the uploaded photo and posts it to `/analyze_image`.
|
| 119 |
2. Backend returns the validated mechanism JSON.
|
| 120 |
-
3. Browser
|
| 121 |
-
|
| 122 |
-
4.
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
| 124 |
instead of model-authored HTML.
|
| 125 |
|
| 126 |
The shell uses Chakra Petch and Fira Code from Bunny Fonts, an asymmetric
|
|
@@ -133,8 +142,8 @@ job is limited to the structured analysis JSON contract in `snap2sim/schema.py`.
|
|
| 133 |
|
| 134 |
## Modal Deployment Path
|
| 135 |
|
| 136 |
-
|
| 137 |
-
|
| 138 |
|
| 139 |
```powershell
|
| 140 |
python -m modal deploy modal_app.py
|
|
@@ -160,8 +169,9 @@ Useful deployment functions/endpoints:
|
|
| 160 |
- `run_analysis_endpoint_check` calls the real llama.cpp analysis task with a
|
| 161 |
synthetic image and confirms it returns a validated mechanism payload.
|
| 162 |
- `runtime_probe` reports the configured model repo, quant, projector file, and
|
| 163 |
-
whether placeholder inference is
|
| 164 |
-
- `analyze_image` preserves the HTTP contract for
|
|
|
|
| 165 |
- `generate_scene` and `generate_scene_llamacpp` are compatibility endpoints
|
| 166 |
that return the deterministic Three.js scene descriptor.
|
| 167 |
- `analyze_image_llamacpp` is the experimental GPU endpoint for the llama.cpp
|
|
@@ -177,9 +187,10 @@ Runtime environment knobs:
|
|
| 177 |
- `SNAP2SIM_SMOKE_GPU`, default `L40S`
|
| 178 |
- `SNAP2SIM_RUNTIME_GPU`, default `L40S`
|
| 179 |
|
| 180 |
-
|
| 181 |
-
`
|
| 182 |
-
|
|
|
|
| 183 |
|
| 184 |
Run the deployment preflight in this order:
|
| 185 |
|
|
@@ -215,8 +226,8 @@ The Space is configured as a Gradio SDK app with these variables:
|
|
| 215 |
```text
|
| 216 |
INFERENCE_BACKEND=modal
|
| 217 |
MODAL_ANALYZE_URL=<stored in Hugging Face Space variables>
|
| 218 |
-
MODAL_GENERATE_URL=<
|
| 219 |
-
INFERENCE_TIMEOUT_SECONDS=
|
| 220 |
```
|
| 221 |
|
| 222 |
The Space also needs `SNAP2SIM_API_TOKEN` as a Hugging Face Space secret. The
|
|
|
|
| 18 |
visualization.
|
| 19 |
|
| 20 |
The local app serves a trusted `index.html` shell through `gradio.Server` and
|
| 21 |
+
exposes `/analyze_image` plus a compatibility `/generate_scene` route. The demo
|
| 22 |
+
path uses the real Modal llama.cpp analysis endpoint by default; the fixed local
|
| 23 |
+
sample backend is available only when `INFERENCE_BACKEND=local` is explicitly
|
| 24 |
+
set for development.
|
| 25 |
|
| 26 |
## Run Locally
|
| 27 |
|
|
|
|
| 30 |
python app.py
|
| 31 |
```
|
| 32 |
|
| 33 |
+
Set `INFERENCE_BACKEND=modal`, `MODAL_ANALYZE_URL`, and `SNAP2SIM_API_TOKEN`
|
| 34 |
+
to point the Gradio app at the deployed Modal `analyze_image_llamacpp`
|
| 35 |
+
endpoint. `/generate_scene` is deterministic in the Space process and returns a
|
| 36 |
+
validated scene descriptor.
|
| 37 |
+
|
| 38 |
+
For quick UI-only development without Modal, explicitly set
|
| 39 |
+
`INFERENCE_BACKEND=local`. That mode returns a sample mechanism and should not
|
| 40 |
+
be used for the public demo.
|
| 41 |
|
| 42 |
For the deployed demo configuration, copy `.env.example` into local environment
|
| 43 |
variables or Hugging Face Space variables. Do not put tokens in `.env.example`;
|
|
|
|
| 123 |
|
| 124 |
1. Browser encodes the uploaded photo and posts it to `/analyze_image`.
|
| 125 |
2. Backend returns the validated mechanism JSON.
|
| 126 |
+
3. Browser posts the analysis to `/generate_scene` for a validated scene
|
| 127 |
+
descriptor.
|
| 128 |
+
4. Browser renders deterministic Three.js primitives when geometry is usable,
|
| 129 |
+
or overlays text-only callouts on the uploaded photo when the model only has
|
| 130 |
+
image-space annotations.
|
| 131 |
+
5. `/generate_scene` returns a validated
|
| 132 |
+
`{ "renderer": "...", "render_mode": "...", "analysis": ... }` descriptor
|
| 133 |
instead of model-authored HTML.
|
| 134 |
|
| 135 |
The shell uses Chakra Petch and Fira Code from Bunny Fonts, an asymmetric
|
|
|
|
| 142 |
|
| 143 |
## Modal Deployment Path
|
| 144 |
|
| 145 |
+
Deploy the Modal app before wiring a public demo so the llama.cpp endpoint uses
|
| 146 |
+
the current prompt, parsing, and coercion code:
|
| 147 |
|
| 148 |
```powershell
|
| 149 |
python -m modal deploy modal_app.py
|
|
|
|
| 169 |
- `run_analysis_endpoint_check` calls the real llama.cpp analysis task with a
|
| 170 |
synthetic image and confirms it returns a validated mechanism payload.
|
| 171 |
- `runtime_probe` reports the configured model repo, quant, projector file, and
|
| 172 |
+
whether placeholder inference is active on the compatibility endpoint.
|
| 173 |
+
- `analyze_image` preserves the HTTP contract for old wiring, but the demo
|
| 174 |
+
should use `analyze_image_llamacpp`.
|
| 175 |
- `generate_scene` and `generate_scene_llamacpp` are compatibility endpoints
|
| 176 |
that return the deterministic Three.js scene descriptor.
|
| 177 |
- `analyze_image_llamacpp` is the experimental GPU endpoint for the llama.cpp
|
|
|
|
| 187 |
- `SNAP2SIM_SMOKE_GPU`, default `L40S`
|
| 188 |
- `SNAP2SIM_RUNTIME_GPU`, default `L40S`
|
| 189 |
|
| 190 |
+
For the public demo, point `MODAL_ANALYZE_URL` at the validated
|
| 191 |
+
`analyze_image_llamacpp` endpoint. `SNAP2SIM_RUNTIME_MODE=placeholder` can stay
|
| 192 |
+
set because only the legacy `analyze_image` compatibility endpoint reads it.
|
| 193 |
+
The browser renders scenes deterministically from the validated analysis JSON.
|
| 194 |
|
| 195 |
Run the deployment preflight in this order:
|
| 196 |
|
|
|
|
| 226 |
```text
|
| 227 |
INFERENCE_BACKEND=modal
|
| 228 |
MODAL_ANALYZE_URL=<stored in Hugging Face Space variables>
|
| 229 |
+
MODAL_GENERATE_URL=<optional compatibility value>
|
| 230 |
+
INFERENCE_TIMEOUT_SECONDS=300
|
| 231 |
```
|
| 232 |
|
| 233 |
The Space also needs `SNAP2SIM_API_TOKEN` as a Hugging Face Space secret. The
|
index.html
CHANGED
|
@@ -166,6 +166,7 @@
|
|
| 166 |
top: 18px;
|
| 167 |
z-index: 10;
|
| 168 |
display: flex;
|
|
|
|
| 169 |
gap: 8px;
|
| 170 |
}
|
| 171 |
|
|
@@ -193,6 +194,25 @@
|
|
| 193 |
cursor: default;
|
| 194 |
}
|
| 195 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
.panel {
|
| 197 |
min-width: 0;
|
| 198 |
overflow: auto;
|
|
@@ -274,8 +294,8 @@
|
|
| 274 |
|
| 275 |
.source-image {
|
| 276 |
width: 100%;
|
| 277 |
-
|
| 278 |
-
object-fit:
|
| 279 |
border: 1px solid rgba(122, 84, 32, 0.72);
|
| 280 |
background: var(--bg);
|
| 281 |
}
|
|
@@ -374,6 +394,64 @@
|
|
| 374 |
pointer-events: none;
|
| 375 |
}
|
| 376 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
@keyframes loading {
|
| 378 |
from { transform: translateX(-100%); }
|
| 379 |
to { transform: translateX(300%); }
|
|
@@ -401,6 +479,10 @@
|
|
| 401 |
border-left: 0;
|
| 402 |
border-top: 1px solid var(--amber-dim);
|
| 403 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
}
|
| 405 |
</style>
|
| 406 |
</head>
|
|
@@ -411,8 +493,10 @@
|
|
| 411 |
<div class="toolbar">
|
| 412 |
<button id="uploadButton" class="tool-button" type="button">Load</button>
|
| 413 |
<button id="playButton" class="tool-button" type="button" disabled>Play</button>
|
|
|
|
| 414 |
</div>
|
| 415 |
<div id="viewport" aria-hidden="true"></div>
|
|
|
|
| 416 |
<label id="dropZone" class="drop-zone" for="fileInput" tabindex="0">Drop component photo</label>
|
| 417 |
<div id="scanLine" class="scan-line"></div>
|
| 418 |
<input id="fileInput" type="file" accept="image/*" capture="environment" hidden>
|
|
@@ -461,8 +545,10 @@
|
|
| 461 |
const fileInput = document.getElementById("fileInput");
|
| 462 |
const uploadButton = document.getElementById("uploadButton");
|
| 463 |
const playButton = document.getElementById("playButton");
|
|
|
|
| 464 |
const dropZone = document.getElementById("dropZone");
|
| 465 |
const viewport = document.getElementById("viewport");
|
|
|
|
| 466 |
const progress = document.getElementById("progress");
|
| 467 |
const scanLine = document.getElementById("scanLine");
|
| 468 |
const statusEl = document.getElementById("status");
|
|
@@ -522,6 +608,9 @@
|
|
| 522 |
playButton.textContent = paused ? "Resume" : "Pause";
|
| 523 |
if (fallbackRuntime) fallbackRuntime.playing = !paused;
|
| 524 |
});
|
|
|
|
|
|
|
|
|
|
| 525 |
|
| 526 |
async function runPipeline(file) {
|
| 527 |
const validationError = validateFile(file);
|
|
@@ -545,13 +634,13 @@
|
|
| 545 |
populateAnalysis(analysis);
|
| 546 |
|
| 547 |
setStatus("RENDERING CUTAWAY...");
|
| 548 |
-
|
|
|
|
| 549 |
} catch (error) {
|
| 550 |
window.clearTimeout(coldStartTimer);
|
| 551 |
setStatus(error.message || String(error), true);
|
| 552 |
retryButton.hidden = false;
|
| 553 |
dropZone.classList.remove("hidden");
|
| 554 |
-
if (window.lastAnalysis) buildDeterministicScene(window.lastAnalysis);
|
| 555 |
} finally {
|
| 556 |
setBusy(false);
|
| 557 |
}
|
|
@@ -564,6 +653,8 @@
|
|
| 564 |
paused = false;
|
| 565 |
playButton.textContent = "Play";
|
| 566 |
playButton.disabled = true;
|
|
|
|
|
|
|
| 567 |
retryButton.hidden = true;
|
| 568 |
viewport.replaceChildren();
|
| 569 |
dropZone.classList.add("hidden");
|
|
@@ -594,6 +685,55 @@
|
|
| 594 |
}));
|
| 595 |
}
|
| 596 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 597 |
function buildDeterministicScene(analysis) {
|
| 598 |
const threeRuntime = window.THREE;
|
| 599 |
if (!threeRuntime) {
|
|
@@ -607,6 +747,8 @@
|
|
| 607 |
paused = false;
|
| 608 |
playButton.textContent = "Pause";
|
| 609 |
playButton.disabled = false;
|
|
|
|
|
|
|
| 610 |
viewport.replaceChildren();
|
| 611 |
const stage = document.createElement("div");
|
| 612 |
stage.className = "fallback-stage";
|
|
@@ -619,20 +761,34 @@
|
|
| 619 |
|
| 620 |
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
| 621 |
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
|
|
| 622 |
mount.appendChild(renderer.domElement);
|
| 623 |
|
| 624 |
const scene = new THREE.Scene();
|
| 625 |
scene.fog = new THREE.Fog(0x0f1318, 8, 20);
|
| 626 |
|
| 627 |
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
|
| 628 |
-
|
|
|
|
|
|
|
| 629 |
|
| 630 |
const controls = window.THREE.OrbitControls
|
| 631 |
? new THREE.OrbitControls(camera, renderer.domElement)
|
| 632 |
: null;
|
| 633 |
if (controls) {
|
| 634 |
controls.enableDamping = true;
|
| 635 |
-
controls.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 636 |
}
|
| 637 |
|
| 638 |
scene.add(new THREE.HemisphereLight(0x5fd4d0, 0x0f1318, 1.2));
|
|
@@ -665,6 +821,13 @@
|
|
| 665 |
let disposed = false;
|
| 666 |
fallbackRuntime = {
|
| 667 |
playing: true,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 668 |
cleanup() {
|
| 669 |
disposed = true;
|
| 670 |
cancelAnimationFrame(frameId);
|
|
@@ -705,6 +868,133 @@
|
|
| 705 |
setStatus("CUTAWAY READY");
|
| 706 |
}
|
| 707 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
function validateFile(file) {
|
| 709 |
if (!file.type || !file.type.startsWith("image/")) {
|
| 710 |
return "Upload an image file.";
|
|
|
|
| 166 |
top: 18px;
|
| 167 |
z-index: 10;
|
| 168 |
display: flex;
|
| 169 |
+
flex-wrap: wrap;
|
| 170 |
gap: 8px;
|
| 171 |
}
|
| 172 |
|
|
|
|
| 194 |
cursor: default;
|
| 195 |
}
|
| 196 |
|
| 197 |
+
.viewport-hint {
|
| 198 |
+
position: absolute;
|
| 199 |
+
left: 18px;
|
| 200 |
+
bottom: 18px;
|
| 201 |
+
z-index: 10;
|
| 202 |
+
max-width: min(320px, calc(100% - 36px));
|
| 203 |
+
border-left: 1px solid var(--cyan-dim);
|
| 204 |
+
padding: 6px 0 6px 10px;
|
| 205 |
+
color: var(--text-muted);
|
| 206 |
+
background: rgba(15, 19, 24, 0.78);
|
| 207 |
+
font: 0.78rem/1.3 "Fira Code", monospace;
|
| 208 |
+
text-transform: uppercase;
|
| 209 |
+
pointer-events: none;
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
.viewport-hint[hidden] {
|
| 213 |
+
display: none;
|
| 214 |
+
}
|
| 215 |
+
|
| 216 |
.panel {
|
| 217 |
min-width: 0;
|
| 218 |
overflow: auto;
|
|
|
|
| 294 |
|
| 295 |
.source-image {
|
| 296 |
width: 100%;
|
| 297 |
+
height: 180px;
|
| 298 |
+
object-fit: contain;
|
| 299 |
border: 1px solid rgba(122, 84, 32, 0.72);
|
| 300 |
background: var(--bg);
|
| 301 |
}
|
|
|
|
| 394 |
pointer-events: none;
|
| 395 |
}
|
| 396 |
|
| 397 |
+
.label-layer {
|
| 398 |
+
pointer-events: none;
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.annotated-stage {
|
| 402 |
+
display: grid;
|
| 403 |
+
place-items: center;
|
| 404 |
+
padding: 58px 18px 44px;
|
| 405 |
+
background: rgba(15, 19, 24, 0.72);
|
| 406 |
+
}
|
| 407 |
+
|
| 408 |
+
.annotation-frame {
|
| 409 |
+
position: relative;
|
| 410 |
+
width: 100%;
|
| 411 |
+
height: 100%;
|
| 412 |
+
display: grid;
|
| 413 |
+
place-items: center;
|
| 414 |
+
}
|
| 415 |
+
|
| 416 |
+
.annotation-image {
|
| 417 |
+
width: 100%;
|
| 418 |
+
height: 100%;
|
| 419 |
+
object-fit: contain;
|
| 420 |
+
border: 1px solid rgba(122, 84, 32, 0.72);
|
| 421 |
+
background: var(--bg);
|
| 422 |
+
}
|
| 423 |
+
|
| 424 |
+
.photo-label {
|
| 425 |
+
max-width: min(220px, 42vw);
|
| 426 |
+
white-space: normal;
|
| 427 |
+
text-transform: none;
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
.label-title {
|
| 431 |
+
display: block;
|
| 432 |
+
color: var(--cyan);
|
| 433 |
+
text-transform: uppercase;
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
.label-note {
|
| 437 |
+
display: block;
|
| 438 |
+
color: var(--text);
|
| 439 |
+
margin-top: 2px;
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.empty-state {
|
| 443 |
+
position: absolute;
|
| 444 |
+
inset: 18px;
|
| 445 |
+
display: grid;
|
| 446 |
+
place-items: center;
|
| 447 |
+
border: 1px dashed var(--danger);
|
| 448 |
+
color: var(--danger);
|
| 449 |
+
background: rgba(15, 19, 24, 0.72);
|
| 450 |
+
text-align: center;
|
| 451 |
+
text-transform: uppercase;
|
| 452 |
+
font-weight: 600;
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
@keyframes loading {
|
| 456 |
from { transform: translateX(-100%); }
|
| 457 |
to { transform: translateX(300%); }
|
|
|
|
| 479 |
border-left: 0;
|
| 480 |
border-top: 1px solid var(--amber-dim);
|
| 481 |
}
|
| 482 |
+
|
| 483 |
+
.source-image {
|
| 484 |
+
height: 220px;
|
| 485 |
+
}
|
| 486 |
}
|
| 487 |
</style>
|
| 488 |
</head>
|
|
|
|
| 493 |
<div class="toolbar">
|
| 494 |
<button id="uploadButton" class="tool-button" type="button">Load</button>
|
| 495 |
<button id="playButton" class="tool-button" type="button" disabled>Play</button>
|
| 496 |
+
<button id="resetViewButton" class="tool-button" type="button" disabled>Reset view</button>
|
| 497 |
</div>
|
| 498 |
<div id="viewport" aria-hidden="true"></div>
|
| 499 |
+
<div id="viewportHint" class="viewport-hint" hidden>Drag to orbit / scroll to zoom</div>
|
| 500 |
<label id="dropZone" class="drop-zone" for="fileInput" tabindex="0">Drop component photo</label>
|
| 501 |
<div id="scanLine" class="scan-line"></div>
|
| 502 |
<input id="fileInput" type="file" accept="image/*" capture="environment" hidden>
|
|
|
|
| 545 |
const fileInput = document.getElementById("fileInput");
|
| 546 |
const uploadButton = document.getElementById("uploadButton");
|
| 547 |
const playButton = document.getElementById("playButton");
|
| 548 |
+
const resetViewButton = document.getElementById("resetViewButton");
|
| 549 |
const dropZone = document.getElementById("dropZone");
|
| 550 |
const viewport = document.getElementById("viewport");
|
| 551 |
+
const viewportHint = document.getElementById("viewportHint");
|
| 552 |
const progress = document.getElementById("progress");
|
| 553 |
const scanLine = document.getElementById("scanLine");
|
| 554 |
const statusEl = document.getElementById("status");
|
|
|
|
| 608 |
playButton.textContent = paused ? "Resume" : "Pause";
|
| 609 |
if (fallbackRuntime) fallbackRuntime.playing = !paused;
|
| 610 |
});
|
| 611 |
+
resetViewButton.addEventListener("click", () => {
|
| 612 |
+
if (fallbackRuntime && fallbackRuntime.resetView) fallbackRuntime.resetView();
|
| 613 |
+
});
|
| 614 |
|
| 615 |
async function runPipeline(file) {
|
| 616 |
const validationError = validateFile(file);
|
|
|
|
| 634 |
populateAnalysis(analysis);
|
| 635 |
|
| 636 |
setStatus("RENDERING CUTAWAY...");
|
| 637 |
+
const scenePayload = await postJson("/generate_scene", { analysis });
|
| 638 |
+
renderScenePayload(scenePayload);
|
| 639 |
} catch (error) {
|
| 640 |
window.clearTimeout(coldStartTimer);
|
| 641 |
setStatus(error.message || String(error), true);
|
| 642 |
retryButton.hidden = false;
|
| 643 |
dropZone.classList.remove("hidden");
|
|
|
|
| 644 |
} finally {
|
| 645 |
setBusy(false);
|
| 646 |
}
|
|
|
|
| 653 |
paused = false;
|
| 654 |
playButton.textContent = "Play";
|
| 655 |
playButton.disabled = true;
|
| 656 |
+
resetViewButton.disabled = true;
|
| 657 |
+
viewportHint.hidden = true;
|
| 658 |
retryButton.hidden = true;
|
| 659 |
viewport.replaceChildren();
|
| 660 |
dropZone.classList.add("hidden");
|
|
|
|
| 685 |
}));
|
| 686 |
}
|
| 687 |
|
| 688 |
+
function renderScenePayload(payload) {
|
| 689 |
+
const analysis = payload && payload.analysis ? payload.analysis : payload;
|
| 690 |
+
const renderMode = payload && payload.render_mode ? payload.render_mode : chooseRenderMode(analysis);
|
| 691 |
+
if (renderMode === "three" && hasUsableGeometry(analysis)) {
|
| 692 |
+
buildDeterministicScene(analysis);
|
| 693 |
+
} else if (renderMode === "annotate" && hasAnnotations(analysis)) {
|
| 694 |
+
buildAnnotatedPhoto(analysis);
|
| 695 |
+
} else {
|
| 696 |
+
buildUnavailableScene();
|
| 697 |
+
}
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
function chooseRenderMode(analysis) {
|
| 701 |
+
const confidence = typeof analysis.confidence === "number" ? analysis.confidence : 1;
|
| 702 |
+
if (hasAnnotations(analysis) && (confidence < 0.5 || !hasUsableGeometry(analysis))) {
|
| 703 |
+
return "annotate";
|
| 704 |
+
}
|
| 705 |
+
if (hasUsableGeometry(analysis) && confidence >= 0.5) {
|
| 706 |
+
return "three";
|
| 707 |
+
}
|
| 708 |
+
if (hasAnnotations(analysis)) {
|
| 709 |
+
return "annotate";
|
| 710 |
+
}
|
| 711 |
+
return "unavailable";
|
| 712 |
+
}
|
| 713 |
+
|
| 714 |
+
function hasUsableGeometry(analysis) {
|
| 715 |
+
return Boolean(analysis && Array.isArray(analysis.parts) && analysis.parts.some((part) => {
|
| 716 |
+
const geometry = part && part.geometry;
|
| 717 |
+
return geometry
|
| 718 |
+
&& ["box", "cylinder", "sphere", "gear", "rod"].includes(geometry.shape)
|
| 719 |
+
&& isNumberList(geometry.size, 3)
|
| 720 |
+
&& isNumberList(geometry.position, 3);
|
| 721 |
+
}));
|
| 722 |
+
}
|
| 723 |
+
|
| 724 |
+
function hasAnnotations(analysis) {
|
| 725 |
+
return Boolean(analysis && Array.isArray(analysis.parts) && analysis.parts.some((part) => {
|
| 726 |
+
const point = part && part.annotation && part.annotation.point;
|
| 727 |
+
return isNumberList(point, 2);
|
| 728 |
+
}));
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
function isNumberList(value, length) {
|
| 732 |
+
return Array.isArray(value)
|
| 733 |
+
&& value.length === length
|
| 734 |
+
&& value.every((item) => typeof item === "number" && Number.isFinite(item));
|
| 735 |
+
}
|
| 736 |
+
|
| 737 |
function buildDeterministicScene(analysis) {
|
| 738 |
const threeRuntime = window.THREE;
|
| 739 |
if (!threeRuntime) {
|
|
|
|
| 747 |
paused = false;
|
| 748 |
playButton.textContent = "Pause";
|
| 749 |
playButton.disabled = false;
|
| 750 |
+
resetViewButton.disabled = false;
|
| 751 |
+
viewportHint.hidden = false;
|
| 752 |
viewport.replaceChildren();
|
| 753 |
const stage = document.createElement("div");
|
| 754 |
stage.className = "fallback-stage";
|
|
|
|
| 761 |
|
| 762 |
const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
|
| 763 |
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
| 764 |
+
renderer.domElement.style.touchAction = "none";
|
| 765 |
mount.appendChild(renderer.domElement);
|
| 766 |
|
| 767 |
const scene = new THREE.Scene();
|
| 768 |
scene.fog = new THREE.Fog(0x0f1318, 8, 20);
|
| 769 |
|
| 770 |
const camera = new THREE.PerspectiveCamera(42, 1, 0.1, 100);
|
| 771 |
+
const defaultCameraPosition = new THREE.Vector3(5.4, 3.9, 6.2);
|
| 772 |
+
const defaultTarget = new THREE.Vector3(0, 0.1, 0);
|
| 773 |
+
camera.position.copy(defaultCameraPosition);
|
| 774 |
|
| 775 |
const controls = window.THREE.OrbitControls
|
| 776 |
? new THREE.OrbitControls(camera, renderer.domElement)
|
| 777 |
: null;
|
| 778 |
if (controls) {
|
| 779 |
controls.enableDamping = true;
|
| 780 |
+
controls.minDistance = 2.4;
|
| 781 |
+
controls.maxDistance = 12;
|
| 782 |
+
controls.maxPolarAngle = Math.PI * 0.48;
|
| 783 |
+
controls.target.copy(defaultTarget);
|
| 784 |
+
if (window.THREE.TOUCH) {
|
| 785 |
+
controls.touches = {
|
| 786 |
+
ONE: THREE.TOUCH.ROTATE,
|
| 787 |
+
TWO: THREE.TOUCH.DOLLY_PAN
|
| 788 |
+
};
|
| 789 |
+
}
|
| 790 |
+
controls.update();
|
| 791 |
+
controls.saveState();
|
| 792 |
}
|
| 793 |
|
| 794 |
scene.add(new THREE.HemisphereLight(0x5fd4d0, 0x0f1318, 1.2));
|
|
|
|
| 821 |
let disposed = false;
|
| 822 |
fallbackRuntime = {
|
| 823 |
playing: true,
|
| 824 |
+
resetView() {
|
| 825 |
+
camera.position.copy(defaultCameraPosition);
|
| 826 |
+
if (controls) {
|
| 827 |
+
controls.target.copy(defaultTarget);
|
| 828 |
+
controls.update();
|
| 829 |
+
}
|
| 830 |
+
},
|
| 831 |
cleanup() {
|
| 832 |
disposed = true;
|
| 833 |
cancelAnimationFrame(frameId);
|
|
|
|
| 868 |
setStatus("CUTAWAY READY");
|
| 869 |
}
|
| 870 |
|
| 871 |
+
function buildAnnotatedPhoto(analysis) {
|
| 872 |
+
if (!currentPreviewUrl) {
|
| 873 |
+
buildUnavailableScene();
|
| 874 |
+
return;
|
| 875 |
+
}
|
| 876 |
+
if (fallbackRuntime && fallbackRuntime.cleanup) fallbackRuntime.cleanup();
|
| 877 |
+
activeMode = "annotate";
|
| 878 |
+
paused = true;
|
| 879 |
+
playButton.textContent = "Play";
|
| 880 |
+
playButton.disabled = true;
|
| 881 |
+
resetViewButton.disabled = true;
|
| 882 |
+
viewportHint.hidden = true;
|
| 883 |
+
viewport.replaceChildren();
|
| 884 |
+
|
| 885 |
+
const stage = document.createElement("div");
|
| 886 |
+
stage.className = "fallback-stage annotated-stage";
|
| 887 |
+
const frame = document.createElement("div");
|
| 888 |
+
frame.className = "annotation-frame";
|
| 889 |
+
const image = document.createElement("img");
|
| 890 |
+
image.className = "annotation-image";
|
| 891 |
+
image.alt = "";
|
| 892 |
+
image.src = currentPreviewUrl;
|
| 893 |
+
const labelLayer = document.createElement("div");
|
| 894 |
+
labelLayer.className = "label-layer";
|
| 895 |
+
frame.append(image, labelLayer);
|
| 896 |
+
stage.append(frame);
|
| 897 |
+
viewport.append(stage);
|
| 898 |
+
|
| 899 |
+
let disposed = false;
|
| 900 |
+
const annotatedParts = (analysis.parts || []).filter((part) => {
|
| 901 |
+
const point = part && part.annotation && part.annotation.point;
|
| 902 |
+
return isNumberList(point, 2);
|
| 903 |
+
});
|
| 904 |
+
|
| 905 |
+
function updatePhotoLabels() {
|
| 906 |
+
if (disposed) return;
|
| 907 |
+
const imageRect = containedImageRect(image);
|
| 908 |
+
const layerRect = labelLayer.getBoundingClientRect();
|
| 909 |
+
labelLayer.replaceChildren(...annotatedParts.map((part, index) => {
|
| 910 |
+
const annotation = part.annotation || {};
|
| 911 |
+
const point = annotation.point || [0.5, 0.5];
|
| 912 |
+
const label = document.createElement("div");
|
| 913 |
+
label.className = "scene-label photo-label";
|
| 914 |
+
label.style.left = Math.min(
|
| 915 |
+
layerRect.width - 72,
|
| 916 |
+
Math.max(72, imageRect.left - layerRect.left + clamp01(point[0]) * imageRect.width)
|
| 917 |
+
) + "px";
|
| 918 |
+
label.style.top = Math.min(
|
| 919 |
+
layerRect.height - 36,
|
| 920 |
+
Math.max(56, imageRect.top - layerRect.top + clamp01(point[1]) * imageRect.height)
|
| 921 |
+
) + "px";
|
| 922 |
+
|
| 923 |
+
const title = document.createElement("span");
|
| 924 |
+
title.className = "label-title";
|
| 925 |
+
title.textContent = (index + 1) + ". " + (annotation.label || part.name || part.id || "part");
|
| 926 |
+
label.append(title);
|
| 927 |
+
const noteText = annotation.note || part.role || "";
|
| 928 |
+
if (noteText) {
|
| 929 |
+
const note = document.createElement("span");
|
| 930 |
+
note.className = "label-note";
|
| 931 |
+
note.textContent = noteText;
|
| 932 |
+
label.append(note);
|
| 933 |
+
}
|
| 934 |
+
return label;
|
| 935 |
+
}));
|
| 936 |
+
}
|
| 937 |
+
|
| 938 |
+
image.addEventListener("load", updatePhotoLabels, { once: true });
|
| 939 |
+
window.addEventListener("resize", updatePhotoLabels);
|
| 940 |
+
fallbackRuntime = {
|
| 941 |
+
playing: false,
|
| 942 |
+
cleanup() {
|
| 943 |
+
disposed = true;
|
| 944 |
+
window.removeEventListener("resize", updatePhotoLabels);
|
| 945 |
+
}
|
| 946 |
+
};
|
| 947 |
+
window.requestAnimationFrame(updatePhotoLabels);
|
| 948 |
+
revealScan();
|
| 949 |
+
setStatus("ANNOTATED PHOTO READY");
|
| 950 |
+
}
|
| 951 |
+
|
| 952 |
+
function buildUnavailableScene() {
|
| 953 |
+
if (fallbackRuntime && fallbackRuntime.cleanup) fallbackRuntime.cleanup();
|
| 954 |
+
fallbackRuntime = null;
|
| 955 |
+
activeMode = "unavailable";
|
| 956 |
+
paused = true;
|
| 957 |
+
playButton.textContent = "Play";
|
| 958 |
+
playButton.disabled = true;
|
| 959 |
+
resetViewButton.disabled = true;
|
| 960 |
+
viewportHint.hidden = true;
|
| 961 |
+
viewport.replaceChildren();
|
| 962 |
+
const empty = document.createElement("div");
|
| 963 |
+
empty.className = "empty-state";
|
| 964 |
+
empty.textContent = "Could not analyze this photo. Try another angle.";
|
| 965 |
+
viewport.append(empty);
|
| 966 |
+
retryButton.hidden = false;
|
| 967 |
+
setStatus("COULD NOT ANALYZE", true);
|
| 968 |
+
}
|
| 969 |
+
|
| 970 |
+
function containedImageRect(image) {
|
| 971 |
+
const rect = image.getBoundingClientRect();
|
| 972 |
+
const naturalWidth = image.naturalWidth || rect.width || 1;
|
| 973 |
+
const naturalHeight = image.naturalHeight || rect.height || 1;
|
| 974 |
+
const rectAspect = rect.width / Math.max(1, rect.height);
|
| 975 |
+
const imageAspect = naturalWidth / Math.max(1, naturalHeight);
|
| 976 |
+
if (rectAspect > imageAspect) {
|
| 977 |
+
const width = rect.height * imageAspect;
|
| 978 |
+
return {
|
| 979 |
+
left: rect.left + (rect.width - width) / 2,
|
| 980 |
+
top: rect.top,
|
| 981 |
+
width,
|
| 982 |
+
height: rect.height
|
| 983 |
+
};
|
| 984 |
+
}
|
| 985 |
+
const height = rect.width / imageAspect;
|
| 986 |
+
return {
|
| 987 |
+
left: rect.left,
|
| 988 |
+
top: rect.top + (rect.height - height) / 2,
|
| 989 |
+
width: rect.width,
|
| 990 |
+
height
|
| 991 |
+
};
|
| 992 |
+
}
|
| 993 |
+
|
| 994 |
+
function clamp01(value) {
|
| 995 |
+
return Math.max(0, Math.min(1, Number(value) || 0));
|
| 996 |
+
}
|
| 997 |
+
|
| 998 |
function validateFile(file) {
|
| 999 |
if (!file.type || !file.type.startsWith("image/")) {
|
| 1000 |
return "Upload an image file.";
|
modal_app.py
CHANGED
|
@@ -18,7 +18,7 @@ from fastapi import Header, HTTPException
|
|
| 18 |
|
| 19 |
from snap2sim.model_io import coerce_analysis_response, parse_analysis_response
|
| 20 |
from snap2sim.prompts import build_vision_prompt
|
| 21 |
-
from snap2sim.schema import EXAMPLE_ANALYSIS, validate_analysis
|
| 22 |
|
| 23 |
|
| 24 |
DEFAULT_MODEL_REPO = "unsloth/NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-GGUF"
|
|
@@ -244,6 +244,8 @@ def smoke_test_llamacpp_image() -> dict[str, Any]:
|
|
| 244 |
prompt,
|
| 245 |
"-n",
|
| 246 |
"1024",
|
|
|
|
|
|
|
| 247 |
"--temp",
|
| 248 |
"0.2",
|
| 249 |
]
|
|
@@ -289,8 +291,9 @@ def smoke_test_llamacpp_image() -> dict[str, Any]:
|
|
| 289 |
def run_llamacpp_prompt(
|
| 290 |
prompt: str,
|
| 291 |
image_path: Path | None = None,
|
| 292 |
-
max_tokens: int =
|
| 293 |
-
timeout_seconds: int =
|
|
|
|
| 294 |
) -> str:
|
| 295 |
"""Run one prompt through the llama.cpp multimodal CLI."""
|
| 296 |
import subprocess
|
|
@@ -306,6 +309,8 @@ def run_llamacpp_prompt(
|
|
| 306 |
prompt,
|
| 307 |
"-n",
|
| 308 |
str(max_tokens),
|
|
|
|
|
|
|
| 309 |
"--temp",
|
| 310 |
"0.2",
|
| 311 |
]
|
|
@@ -383,8 +388,9 @@ def analyze_image_llamacpp_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
|
| 383 |
response = run_llamacpp_prompt(
|
| 384 |
build_vision_prompt(),
|
| 385 |
image_path=image_path,
|
| 386 |
-
max_tokens=
|
| 387 |
-
timeout_seconds=
|
|
|
|
| 388 |
)
|
| 389 |
try:
|
| 390 |
return parse_analysis_response(response)
|
|
@@ -432,6 +438,28 @@ def run_analysis_endpoint_check() -> None:
|
|
| 432 |
print(json.dumps(result, indent=2))
|
| 433 |
|
| 434 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
@app.function(image=image, timeout=120, secrets=[api_auth_secret])
|
| 436 |
@modal.fastapi_endpoint(method="GET")
|
| 437 |
def runtime_probe(authorization: str = Header(default="")) -> dict[str, Any]:
|
|
@@ -473,6 +501,44 @@ def analyze_image_llamacpp_task(payload: dict[str, Any]) -> dict[str, Any]:
|
|
| 473 |
return analyze_image_llamacpp_payload(payload)
|
| 474 |
|
| 475 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 476 |
@app.function(image=llamacpp_image, gpu=os.getenv("SNAP2SIM_RUNTIME_GPU", "L40S"), volumes={CACHE_DIR: model_cache}, timeout=60 * 60, secrets=[api_auth_secret])
|
| 477 |
@modal.fastapi_endpoint(method="POST")
|
| 478 |
def analyze_image_llamacpp(payload: dict[str, Any], authorization: str = Header(default="")) -> dict[str, Any]:
|
|
@@ -490,7 +556,9 @@ def generate_scene(payload: dict[str, Any], authorization: str = Header(default=
|
|
| 490 |
raise NotImplementedError(
|
| 491 |
"Scene generation is deterministic in the browser from validated JSON."
|
| 492 |
)
|
| 493 |
-
|
|
|
|
|
|
|
| 494 |
|
| 495 |
|
| 496 |
@app.function(image=llamacpp_image, gpu=os.getenv("SNAP2SIM_RUNTIME_GPU", "L40S"), volumes={CACHE_DIR: model_cache}, timeout=60 * 60, secrets=[api_auth_secret])
|
|
@@ -499,4 +567,6 @@ def generate_scene_llamacpp(payload: dict[str, Any], authorization: str = Header
|
|
| 499 |
"""Compatibility endpoint; scene rendering is deterministic browser-side."""
|
| 500 |
require_authorization(authorization)
|
| 501 |
analysis = validate_analysis(payload.get("analysis") or EXAMPLE_ANALYSIS)
|
| 502 |
-
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
from snap2sim.model_io import coerce_analysis_response, parse_analysis_response
|
| 20 |
from snap2sim.prompts import build_vision_prompt
|
| 21 |
+
from snap2sim.schema import EXAMPLE_ANALYSIS, select_render_mode, validate_analysis
|
| 22 |
|
| 23 |
|
| 24 |
DEFAULT_MODEL_REPO = "unsloth/NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-GGUF"
|
|
|
|
| 244 |
prompt,
|
| 245 |
"-n",
|
| 246 |
"1024",
|
| 247 |
+
"-c",
|
| 248 |
+
"4096",
|
| 249 |
"--temp",
|
| 250 |
"0.2",
|
| 251 |
]
|
|
|
|
| 291 |
def run_llamacpp_prompt(
|
| 292 |
prompt: str,
|
| 293 |
image_path: Path | None = None,
|
| 294 |
+
max_tokens: int = 3072,
|
| 295 |
+
timeout_seconds: int = 300,
|
| 296 |
+
ctx_size: int = 8192,
|
| 297 |
) -> str:
|
| 298 |
"""Run one prompt through the llama.cpp multimodal CLI."""
|
| 299 |
import subprocess
|
|
|
|
| 309 |
prompt,
|
| 310 |
"-n",
|
| 311 |
str(max_tokens),
|
| 312 |
+
"-c",
|
| 313 |
+
str(ctx_size),
|
| 314 |
"--temp",
|
| 315 |
"0.2",
|
| 316 |
]
|
|
|
|
| 388 |
response = run_llamacpp_prompt(
|
| 389 |
build_vision_prompt(),
|
| 390 |
image_path=image_path,
|
| 391 |
+
max_tokens=4096,
|
| 392 |
+
timeout_seconds=300,
|
| 393 |
+
ctx_size=8192,
|
| 394 |
)
|
| 395 |
try:
|
| 396 |
return parse_analysis_response(response)
|
|
|
|
| 438 |
print(json.dumps(result, indent=2))
|
| 439 |
|
| 440 |
|
| 441 |
+
@app.local_entrypoint()
|
| 442 |
+
def run_analysis_raw_check() -> None:
|
| 443 |
+
"""Print raw llama.cpp analysis output diagnostics for prompt tuning."""
|
| 444 |
+
import base64
|
| 445 |
+
import json
|
| 446 |
+
from io import BytesIO
|
| 447 |
+
|
| 448 |
+
from PIL import Image, ImageDraw
|
| 449 |
+
|
| 450 |
+
img = Image.new("RGB", (512, 384), "#d8d0bd")
|
| 451 |
+
draw = ImageDraw.Draw(img)
|
| 452 |
+
draw.rectangle((82, 96, 430, 288), outline="#2c3138", width=8)
|
| 453 |
+
draw.ellipse((178, 112, 334, 268), outline="#b06c23", width=14)
|
| 454 |
+
draw.line((256, 112, 256, 268), fill="#2c3138", width=6)
|
| 455 |
+
draw.line((178, 190, 334, 190), fill="#2c3138", width=6)
|
| 456 |
+
buffer = BytesIO()
|
| 457 |
+
img.save(buffer, format="JPEG", quality=92)
|
| 458 |
+
payload = {"image_base64": base64.b64encode(buffer.getvalue()).decode("ascii")}
|
| 459 |
+
result = analyze_image_llamacpp_raw_task.remote(payload)
|
| 460 |
+
print(json.dumps(result, indent=2))
|
| 461 |
+
|
| 462 |
+
|
| 463 |
@app.function(image=image, timeout=120, secrets=[api_auth_secret])
|
| 464 |
@modal.fastapi_endpoint(method="GET")
|
| 465 |
def runtime_probe(authorization: str = Header(default="")) -> dict[str, Any]:
|
|
|
|
| 501 |
return analyze_image_llamacpp_payload(payload)
|
| 502 |
|
| 503 |
|
| 504 |
+
@app.function(image=llamacpp_image, gpu=os.getenv("SNAP2SIM_RUNTIME_GPU", "L40S"), volumes={CACHE_DIR: model_cache}, timeout=60 * 60)
|
| 505 |
+
def analyze_image_llamacpp_raw_task(payload: dict[str, Any]) -> dict[str, Any]:
|
| 506 |
+
"""Remote-callable diagnostic task for prompt and budget tuning."""
|
| 507 |
+
import time
|
| 508 |
+
|
| 509 |
+
image_path = write_payload_image(payload)
|
| 510 |
+
start = time.monotonic()
|
| 511 |
+
try:
|
| 512 |
+
response = run_llamacpp_prompt(
|
| 513 |
+
build_vision_prompt(),
|
| 514 |
+
image_path=image_path,
|
| 515 |
+
max_tokens=4096,
|
| 516 |
+
timeout_seconds=300,
|
| 517 |
+
ctx_size=8192,
|
| 518 |
+
)
|
| 519 |
+
finally:
|
| 520 |
+
image_path.unlink(missing_ok=True)
|
| 521 |
+
|
| 522 |
+
elapsed_seconds = round(time.monotonic() - start, 2)
|
| 523 |
+
parse_error = ""
|
| 524 |
+
parsed: dict[str, Any] | None = None
|
| 525 |
+
try:
|
| 526 |
+
parsed = parse_analysis_response(response)
|
| 527 |
+
except Exception as exc:
|
| 528 |
+
parse_error = str(exc)
|
| 529 |
+
coerced = coerce_analysis_response(response)
|
| 530 |
+
return {
|
| 531 |
+
"elapsed_seconds": elapsed_seconds,
|
| 532 |
+
"parse_ok": parsed is not None,
|
| 533 |
+
"parse_error": parse_error,
|
| 534 |
+
"parsed_component": parsed["component"] if parsed else "",
|
| 535 |
+
"coerced_component": coerced["component"],
|
| 536 |
+
"coerced_render_mode": select_render_mode(coerced),
|
| 537 |
+
"coerced_confidence": coerced.get("confidence"),
|
| 538 |
+
"stdout_tail": response[-4000:],
|
| 539 |
+
}
|
| 540 |
+
|
| 541 |
+
|
| 542 |
@app.function(image=llamacpp_image, gpu=os.getenv("SNAP2SIM_RUNTIME_GPU", "L40S"), volumes={CACHE_DIR: model_cache}, timeout=60 * 60, secrets=[api_auth_secret])
|
| 543 |
@modal.fastapi_endpoint(method="POST")
|
| 544 |
def analyze_image_llamacpp(payload: dict[str, Any], authorization: str = Header(default="")) -> dict[str, Any]:
|
|
|
|
| 556 |
raise NotImplementedError(
|
| 557 |
"Scene generation is deterministic in the browser from validated JSON."
|
| 558 |
)
|
| 559 |
+
render_mode = select_render_mode(analysis)
|
| 560 |
+
renderer = "three" if render_mode == "three" else "photo"
|
| 561 |
+
return {"renderer": renderer, "render_mode": render_mode, "analysis": analysis}
|
| 562 |
|
| 563 |
|
| 564 |
@app.function(image=llamacpp_image, gpu=os.getenv("SNAP2SIM_RUNTIME_GPU", "L40S"), volumes={CACHE_DIR: model_cache}, timeout=60 * 60, secrets=[api_auth_secret])
|
|
|
|
| 567 |
"""Compatibility endpoint; scene rendering is deterministic browser-side."""
|
| 568 |
require_authorization(authorization)
|
| 569 |
analysis = validate_analysis(payload.get("analysis") or EXAMPLE_ANALYSIS)
|
| 570 |
+
render_mode = select_render_mode(analysis)
|
| 571 |
+
renderer = "three" if render_mode == "three" else "photo"
|
| 572 |
+
return {"renderer": renderer, "render_mode": render_mode, "analysis": analysis}
|
snap2sim/backend.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Backend client
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
@@ -11,16 +11,16 @@ from typing import Any
|
|
| 11 |
import requests
|
| 12 |
from PIL import Image
|
| 13 |
|
| 14 |
-
from snap2sim.schema import EXAMPLE_ANALYSIS, validate_analysis
|
| 15 |
|
| 16 |
|
| 17 |
@dataclass(frozen=True)
|
| 18 |
class Settings:
|
| 19 |
-
backend: str = os.getenv("INFERENCE_BACKEND", "
|
| 20 |
analyze_url: str = os.getenv("MODAL_ANALYZE_URL", "")
|
| 21 |
generate_url: str = os.getenv("MODAL_GENERATE_URL", "")
|
| 22 |
api_token: str = os.getenv("SNAP2SIM_API_TOKEN", "")
|
| 23 |
-
timeout_seconds: int = int(os.getenv("INFERENCE_TIMEOUT_SECONDS", "
|
| 24 |
|
| 25 |
|
| 26 |
def encode_image(image: Image.Image) -> str:
|
|
@@ -46,7 +46,9 @@ class InferenceClient:
|
|
| 46 |
|
| 47 |
def generate_scene(self, analysis: dict[str, Any]) -> dict[str, Any]:
|
| 48 |
valid_analysis = validate_analysis(analysis)
|
| 49 |
-
|
|
|
|
|
|
|
| 50 |
|
| 51 |
def _post_json(self, url: str, payload: dict[str, Any]) -> dict[str, Any]:
|
| 52 |
if not url:
|
|
|
|
| 1 |
+
"""Backend client for Modal inference with explicit local sample mode."""
|
| 2 |
|
| 3 |
from __future__ import annotations
|
| 4 |
|
|
|
|
| 11 |
import requests
|
| 12 |
from PIL import Image
|
| 13 |
|
| 14 |
+
from snap2sim.schema import EXAMPLE_ANALYSIS, select_render_mode, validate_analysis
|
| 15 |
|
| 16 |
|
| 17 |
@dataclass(frozen=True)
|
| 18 |
class Settings:
|
| 19 |
+
backend: str = os.getenv("INFERENCE_BACKEND", "modal")
|
| 20 |
analyze_url: str = os.getenv("MODAL_ANALYZE_URL", "")
|
| 21 |
generate_url: str = os.getenv("MODAL_GENERATE_URL", "")
|
| 22 |
api_token: str = os.getenv("SNAP2SIM_API_TOKEN", "")
|
| 23 |
+
timeout_seconds: int = int(os.getenv("INFERENCE_TIMEOUT_SECONDS", "300"))
|
| 24 |
|
| 25 |
|
| 26 |
def encode_image(image: Image.Image) -> str:
|
|
|
|
| 46 |
|
| 47 |
def generate_scene(self, analysis: dict[str, Any]) -> dict[str, Any]:
|
| 48 |
valid_analysis = validate_analysis(analysis)
|
| 49 |
+
render_mode = select_render_mode(valid_analysis)
|
| 50 |
+
renderer = "three" if render_mode == "three" else "photo"
|
| 51 |
+
return {"renderer": renderer, "render_mode": render_mode, "analysis": valid_analysis}
|
| 52 |
|
| 53 |
def _post_json(self, url: str, payload: dict[str, Any]) -> dict[str, Any]:
|
| 54 |
if not url:
|
snap2sim/model_io.py
CHANGED
|
@@ -10,13 +10,14 @@ from snap2sim.schema import validate_analysis
|
|
| 10 |
|
| 11 |
|
| 12 |
_FENCE_RE = re.compile(r"^```(?:json|html)?\s*|\s*```$", re.IGNORECASE)
|
|
|
|
| 13 |
|
| 14 |
|
| 15 |
def parse_analysis_response(text: str) -> dict[str, Any]:
|
| 16 |
"""Extract and validate a JSON object from a model response."""
|
| 17 |
raw = _strip_fences(text)
|
| 18 |
errors: list[str] = []
|
| 19 |
-
for start, json_text in _json_object_candidates(raw):
|
| 20 |
try:
|
| 21 |
payload = json.loads(json_text)
|
| 22 |
return validate_analysis(payload)
|
|
@@ -32,13 +33,15 @@ def coerce_analysis_response(text: str) -> dict[str, Any]:
|
|
| 32 |
"""Best-effort conversion of partial model output into a valid analysis."""
|
| 33 |
raw = _strip_fences(text)
|
| 34 |
fallback_component = _infer_component(raw)
|
| 35 |
-
for _, json_text in _json_object_candidates(raw):
|
| 36 |
try:
|
| 37 |
payload = json.loads(json_text)
|
| 38 |
except json.JSONDecodeError:
|
| 39 |
continue
|
| 40 |
if not isinstance(payload, dict):
|
| 41 |
continue
|
|
|
|
|
|
|
| 42 |
try:
|
| 43 |
return validate_analysis(_coerce_analysis_payload(payload, fallback_component))
|
| 44 |
except ValueError:
|
|
@@ -47,7 +50,21 @@ def coerce_analysis_response(text: str) -> dict[str, Any]:
|
|
| 47 |
|
| 48 |
|
| 49 |
def _strip_fences(text: str) -> str:
|
| 50 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
|
| 52 |
|
| 53 |
def _json_object_candidates(text: str) -> list[tuple[int, str]]:
|
|
@@ -89,6 +106,10 @@ def _balanced_json_object(text: str, start: int) -> str:
|
|
| 89 |
raise ValueError("Model response contained an unterminated JSON object.")
|
| 90 |
|
| 91 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
def _coerce_analysis_payload(payload: dict[str, Any], fallback_component: str) -> dict[str, Any]:
|
| 93 |
parts = payload.get("parts")
|
| 94 |
if not isinstance(parts, list):
|
|
@@ -102,7 +123,7 @@ def _coerce_analysis_payload(payload: dict[str, Any], fallback_component: str) -
|
|
| 102 |
if not isinstance(confidence, (int, float)) or isinstance(confidence, bool):
|
| 103 |
confidence = 0.55
|
| 104 |
|
| 105 |
-
|
| 106 |
"component": _non_empty_string(payload.get("component"), fallback_component),
|
| 107 |
"confidence": max(0.0, min(1.0, float(confidence))),
|
| 108 |
"summary": _non_empty_string(
|
|
@@ -116,16 +137,31 @@ def _coerce_analysis_payload(payload: dict[str, Any], fallback_component: str) -
|
|
| 116 |
),
|
| 117 |
"parts": coerced_parts,
|
| 118 |
}
|
|
|
|
|
|
|
|
|
|
| 119 |
|
| 120 |
|
| 121 |
def _coerce_part(part: dict[str, Any], index: int) -> dict[str, Any] | None:
|
|
|
|
| 122 |
geometry = part.get("geometry")
|
| 123 |
-
|
|
|
|
| 124 |
geometry = {}
|
| 125 |
motion = part.get("motion")
|
| 126 |
if not isinstance(motion, dict):
|
| 127 |
motion = {}
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
shape = geometry.get("shape")
|
| 130 |
if shape not in {"box", "cylinder", "sphere", "gear", "rod"}:
|
| 131 |
shape = "box"
|
|
@@ -134,10 +170,9 @@ def _coerce_part(part: dict[str, Any], index: int) -> dict[str, Any] | None:
|
|
| 134 |
motion_type = "static"
|
| 135 |
|
| 136 |
coerced_motion: dict[str, Any] = {"type": motion_type}
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
coerced_motion[key] = values
|
| 141 |
for key in ["speed", "amplitude", "phase"]:
|
| 142 |
if isinstance(motion.get(key), (int, float)) and not isinstance(motion.get(key), bool):
|
| 143 |
coerced_motion[key] = float(motion[key])
|
|
@@ -147,7 +182,7 @@ def _coerce_part(part: dict[str, Any], index: int) -> dict[str, Any] | None:
|
|
| 147 |
|
| 148 |
coerced_geometry: dict[str, Any] = {
|
| 149 |
"shape": shape,
|
| 150 |
-
"size":
|
| 151 |
"position": _number_list(geometry.get("position"), 3) or [float(index) - 1.0, 0.0, 0.0],
|
| 152 |
}
|
| 153 |
values = _number_list(geometry.get("rotation"), 3)
|
|
@@ -158,13 +193,9 @@ def _coerce_part(part: dict[str, Any], index: int) -> dict[str, Any] | None:
|
|
| 158 |
if isinstance(geometry.get("color"), str) and geometry["color"].strip():
|
| 159 |
coerced_geometry["color"] = geometry["color"].strip()
|
| 160 |
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
"role": _non_empty_string(part.get("role"), "inferred mechanical element"),
|
| 165 |
-
"geometry": coerced_geometry,
|
| 166 |
-
"motion": coerced_motion,
|
| 167 |
-
}
|
| 168 |
|
| 169 |
|
| 170 |
def _generic_analysis(component: str) -> dict[str, Any]:
|
|
@@ -235,6 +266,71 @@ def _number_list(value: Any, length: int) -> list[float] | None:
|
|
| 235 |
return [float(item) for item in value]
|
| 236 |
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
def _identifier(value: Any, fallback: str) -> str:
|
| 239 |
text = _non_empty_string(value, fallback).lower()
|
| 240 |
text = re.sub(r"[^a-z0-9_]+", "_", text).strip("_")
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
_FENCE_RE = re.compile(r"^```(?:json|html)?\s*|\s*```$", re.IGNORECASE)
|
| 13 |
+
_THINK_RE = re.compile(r"<think\b[^>]*>.*?</think>", re.IGNORECASE | re.DOTALL)
|
| 14 |
|
| 15 |
|
| 16 |
def parse_analysis_response(text: str) -> dict[str, Any]:
|
| 17 |
"""Extract and validate a JSON object from a model response."""
|
| 18 |
raw = _strip_fences(text)
|
| 19 |
errors: list[str] = []
|
| 20 |
+
for start, json_text in reversed(_json_object_candidates(raw)):
|
| 21 |
try:
|
| 22 |
payload = json.loads(json_text)
|
| 23 |
return validate_analysis(payload)
|
|
|
|
| 33 |
"""Best-effort conversion of partial model output into a valid analysis."""
|
| 34 |
raw = _strip_fences(text)
|
| 35 |
fallback_component = _infer_component(raw)
|
| 36 |
+
for _, json_text in reversed(_json_object_candidates(raw)):
|
| 37 |
try:
|
| 38 |
payload = json.loads(json_text)
|
| 39 |
except json.JSONDecodeError:
|
| 40 |
continue
|
| 41 |
if not isinstance(payload, dict):
|
| 42 |
continue
|
| 43 |
+
if not _looks_like_analysis_payload(payload):
|
| 44 |
+
continue
|
| 45 |
try:
|
| 46 |
return validate_analysis(_coerce_analysis_payload(payload, fallback_component))
|
| 47 |
except ValueError:
|
|
|
|
| 50 |
|
| 51 |
|
| 52 |
def _strip_fences(text: str) -> str:
|
| 53 |
+
raw = _FENCE_RE.sub("", text.strip()).strip()
|
| 54 |
+
return _strip_reasoning(raw)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _strip_reasoning(text: str) -> str:
|
| 58 |
+
cleaned = _THINK_RE.sub("", text)
|
| 59 |
+
unclosed = re.search(r"<think\b[^>]*>", cleaned, re.IGNORECASE)
|
| 60 |
+
if unclosed:
|
| 61 |
+
after = cleaned[unclosed.end() :]
|
| 62 |
+
json_start = after.find("{")
|
| 63 |
+
if json_start >= 0:
|
| 64 |
+
cleaned = cleaned[: unclosed.start()] + after[json_start:]
|
| 65 |
+
else:
|
| 66 |
+
cleaned = cleaned[: unclosed.start()]
|
| 67 |
+
return cleaned.strip()
|
| 68 |
|
| 69 |
|
| 70 |
def _json_object_candidates(text: str) -> list[tuple[int, str]]:
|
|
|
|
| 106 |
raise ValueError("Model response contained an unterminated JSON object.")
|
| 107 |
|
| 108 |
|
| 109 |
+
def _looks_like_analysis_payload(payload: dict[str, Any]) -> bool:
|
| 110 |
+
return any(key in payload for key in ["component", "summary", "trigger", "motion_sequence", "parts"])
|
| 111 |
+
|
| 112 |
+
|
| 113 |
def _coerce_analysis_payload(payload: dict[str, Any], fallback_component: str) -> dict[str, Any]:
|
| 114 |
parts = payload.get("parts")
|
| 115 |
if not isinstance(parts, list):
|
|
|
|
| 123 |
if not isinstance(confidence, (int, float)) or isinstance(confidence, bool):
|
| 124 |
confidence = 0.55
|
| 125 |
|
| 126 |
+
result = {
|
| 127 |
"component": _non_empty_string(payload.get("component"), fallback_component),
|
| 128 |
"confidence": max(0.0, min(1.0, float(confidence))),
|
| 129 |
"summary": _non_empty_string(
|
|
|
|
| 137 |
),
|
| 138 |
"parts": coerced_parts,
|
| 139 |
}
|
| 140 |
+
if payload.get("render_mode") in {"three", "annotate", "unavailable"}:
|
| 141 |
+
result["render_mode"] = payload["render_mode"]
|
| 142 |
+
return result
|
| 143 |
|
| 144 |
|
| 145 |
def _coerce_part(part: dict[str, Any], index: int) -> dict[str, Any] | None:
|
| 146 |
+
annotation = _coerce_annotation(part.get("annotation"))
|
| 147 |
geometry = part.get("geometry")
|
| 148 |
+
has_geometry_input = isinstance(geometry, dict) and bool(geometry)
|
| 149 |
+
if not has_geometry_input:
|
| 150 |
geometry = {}
|
| 151 |
motion = part.get("motion")
|
| 152 |
if not isinstance(motion, dict):
|
| 153 |
motion = {}
|
| 154 |
|
| 155 |
+
base_part: dict[str, Any] = {
|
| 156 |
+
"id": _identifier(part.get("id"), f"part_{index + 1}"),
|
| 157 |
+
"name": _non_empty_string(part.get("name"), f"part {index + 1}"),
|
| 158 |
+
"role": _non_empty_string(part.get("role"), "inferred mechanical element"),
|
| 159 |
+
}
|
| 160 |
+
if annotation:
|
| 161 |
+
base_part["annotation"] = annotation
|
| 162 |
+
if annotation and not has_geometry_input:
|
| 163 |
+
return base_part
|
| 164 |
+
|
| 165 |
shape = geometry.get("shape")
|
| 166 |
if shape not in {"box", "cylinder", "sphere", "gear", "rod"}:
|
| 167 |
shape = "box"
|
|
|
|
| 170 |
motion_type = "static"
|
| 171 |
|
| 172 |
coerced_motion: dict[str, Any] = {"type": motion_type}
|
| 173 |
+
axis = _axis_vector(motion.get("axis"))
|
| 174 |
+
if axis:
|
| 175 |
+
coerced_motion["axis"] = axis
|
|
|
|
| 176 |
for key in ["speed", "amplitude", "phase"]:
|
| 177 |
if isinstance(motion.get(key), (int, float)) and not isinstance(motion.get(key), bool):
|
| 178 |
coerced_motion[key] = float(motion[key])
|
|
|
|
| 182 |
|
| 183 |
coerced_geometry: dict[str, Any] = {
|
| 184 |
"shape": shape,
|
| 185 |
+
"size": _geometry_size(geometry, shape),
|
| 186 |
"position": _number_list(geometry.get("position"), 3) or [float(index) - 1.0, 0.0, 0.0],
|
| 187 |
}
|
| 188 |
values = _number_list(geometry.get("rotation"), 3)
|
|
|
|
| 193 |
if isinstance(geometry.get("color"), str) and geometry["color"].strip():
|
| 194 |
coerced_geometry["color"] = geometry["color"].strip()
|
| 195 |
|
| 196 |
+
base_part["geometry"] = coerced_geometry
|
| 197 |
+
base_part["motion"] = coerced_motion
|
| 198 |
+
return base_part
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
|
| 200 |
|
| 201 |
def _generic_analysis(component: str) -> dict[str, Any]:
|
|
|
|
| 266 |
return [float(item) for item in value]
|
| 267 |
|
| 268 |
|
| 269 |
+
def _geometry_size(geometry: dict[str, Any], shape: str) -> list[float]:
|
| 270 |
+
size = _number_list(geometry.get("size"), 3)
|
| 271 |
+
if size:
|
| 272 |
+
return size
|
| 273 |
+
|
| 274 |
+
radius = _number_value(geometry.get("radius"))
|
| 275 |
+
height = _number_value(geometry.get("height"))
|
| 276 |
+
length = _number_value(geometry.get("length"))
|
| 277 |
+
width = _number_value(geometry.get("width"))
|
| 278 |
+
depth = _number_value(geometry.get("depth"))
|
| 279 |
+
if shape in {"cylinder", "gear"} and radius and height:
|
| 280 |
+
diameter = radius * 2
|
| 281 |
+
return [diameter, height, diameter]
|
| 282 |
+
if shape == "rod" and (length or height) and radius:
|
| 283 |
+
diameter = radius * 2
|
| 284 |
+
return [diameter, diameter, length or height or 1.0]
|
| 285 |
+
if width and height and depth:
|
| 286 |
+
return [width, height, depth]
|
| 287 |
+
return [1.0, 0.4, 1.0]
|
| 288 |
+
|
| 289 |
+
|
| 290 |
+
def _axis_vector(value: Any) -> list[float] | None:
|
| 291 |
+
vector = _number_list(value, 3)
|
| 292 |
+
if vector:
|
| 293 |
+
return vector
|
| 294 |
+
if isinstance(value, str):
|
| 295 |
+
key = value.strip().lower()
|
| 296 |
+
if key == "x":
|
| 297 |
+
return [1.0, 0.0, 0.0]
|
| 298 |
+
if key == "y":
|
| 299 |
+
return [0.0, 1.0, 0.0]
|
| 300 |
+
if key == "z":
|
| 301 |
+
return [0.0, 0.0, 1.0]
|
| 302 |
+
return None
|
| 303 |
+
|
| 304 |
+
|
| 305 |
+
def _number_value(value: Any) -> float | None:
|
| 306 |
+
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
| 307 |
+
return float(value)
|
| 308 |
+
return None
|
| 309 |
+
|
| 310 |
+
|
| 311 |
+
def _unit_number_list(value: Any, length: int) -> list[float] | None:
|
| 312 |
+
values = _number_list(value, length)
|
| 313 |
+
if not values or not all(0 <= item <= 1 for item in values):
|
| 314 |
+
return None
|
| 315 |
+
return values
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def _coerce_annotation(value: Any) -> dict[str, Any] | None:
|
| 319 |
+
if not isinstance(value, dict):
|
| 320 |
+
return None
|
| 321 |
+
point = _unit_number_list(value.get("point"), 2)
|
| 322 |
+
if not point:
|
| 323 |
+
return None
|
| 324 |
+
annotation: dict[str, Any] = {"point": point}
|
| 325 |
+
box = _unit_number_list(value.get("box"), 4)
|
| 326 |
+
if box:
|
| 327 |
+
annotation["box"] = box
|
| 328 |
+
for key in ["label", "note"]:
|
| 329 |
+
if isinstance(value.get(key), str) and value[key].strip():
|
| 330 |
+
annotation[key] = value[key].strip()
|
| 331 |
+
return annotation
|
| 332 |
+
|
| 333 |
+
|
| 334 |
def _identifier(value: Any, fallback: str) -> str:
|
| 335 |
text = _non_empty_string(value, fallback).lower()
|
| 336 |
text = re.sub(r"[^a-z0-9_]+", "_", text).strip("_")
|
snap2sim/prompts.py
CHANGED
|
@@ -6,21 +6,32 @@ VISION_SYSTEM_PROMPT = """You are a mechanical teardown analyst.
|
|
| 6 |
Given an image of a hardware component, infer the most likely internal
|
| 7 |
mechanism and return only JSON matching the provided schema. Prefer clear,
|
| 8 |
physically plausible primitive geometry over speculative detail. If the photo is
|
| 9 |
-
ambiguous, state lower confidence and
|
|
|
|
| 10 |
|
| 11 |
|
| 12 |
def build_vision_prompt() -> str:
|
| 13 |
return (
|
| 14 |
"Analyze the uploaded hardware component as a cutaway mechanism. "
|
| 15 |
-
"
|
| 16 |
-
"
|
| 17 |
-
"plausible for primitive Three.js rendering.\n\n"
|
| 18 |
"Required top-level keys: component, confidence, summary, trigger, "
|
| 19 |
-
"motion_sequence, parts.
|
| 20 |
-
"
|
|
|
|
|
|
|
|
|
|
| 21 |
"Allowed geometry.shape values: box, cylinder, sphere, gear, rod.\n"
|
| 22 |
-
"Allowed motion.type values: rotate, translate, oscillate, static.\n
|
| 23 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
"Use this shape:\n"
|
| 25 |
"{\n"
|
| 26 |
' "component": "short component name",\n'
|
|
@@ -34,7 +45,8 @@ def build_vision_prompt() -> str:
|
|
| 34 |
' "name": "part name",\n'
|
| 35 |
' "role": "mechanical role",\n'
|
| 36 |
' "geometry": {"shape": "box", "size": [1, 1, 1], "position": [0, 0, 0]},\n'
|
| 37 |
-
' "motion": {"type": "static"}\n'
|
|
|
|
| 38 |
" }\n"
|
| 39 |
" ]\n"
|
| 40 |
"}\n\n"
|
|
|
|
| 6 |
Given an image of a hardware component, infer the most likely internal
|
| 7 |
mechanism and return only JSON matching the provided schema. Prefer clear,
|
| 8 |
physically plausible primitive geometry over speculative detail. If the photo is
|
| 9 |
+
ambiguous, state lower confidence and identify visible components with photo
|
| 10 |
+
annotations instead of forcing a speculative 3D mechanism."""
|
| 11 |
|
| 12 |
|
| 13 |
def build_vision_prompt() -> str:
|
| 14 |
return (
|
| 15 |
"Analyze the uploaded hardware component as a cutaway mechanism. "
|
| 16 |
+
"You may reason first if needed, but the final answer must be one JSON "
|
| 17 |
+
"object. Do not include markdown. Keep the final payload compact and "
|
| 18 |
+
"physically plausible for primitive Three.js rendering.\n\n"
|
| 19 |
"Required top-level keys: component, confidence, summary, trigger, "
|
| 20 |
+
"motion_sequence, parts. Optional top-level render_mode is three, "
|
| 21 |
+
"annotate, or unavailable.\n"
|
| 22 |
+
"Each part requires: id, name, role, and either geometry plus motion, "
|
| 23 |
+
"or annotation when the visible component can be located but 3D "
|
| 24 |
+
"geometry is uncertain.\n"
|
| 25 |
"Allowed geometry.shape values: box, cylinder, sphere, gear, rod.\n"
|
| 26 |
+
"Allowed motion.type values: rotate, translate, oscillate, static.\n"
|
| 27 |
+
"Every geometry must use size: [x, y, z] and position: [x, y, z]. "
|
| 28 |
+
"Do not use radius, height, length, width, or depth fields. Every "
|
| 29 |
+
"motion axis must be a numeric vector such as [0, 1, 0], never a string "
|
| 30 |
+
"like x, y, or z.\n\n"
|
| 31 |
+
"Use 2 to 4 parts. Keep names and descriptions short. When possible, "
|
| 32 |
+
"include annotation.point in normalized image coordinates [x, y] with "
|
| 33 |
+
"origin at top-left, plus a short annotation.note. Optional "
|
| 34 |
+
"annotation.box is [x, y, width, height], also normalized from 0 to 1.\n\n"
|
| 35 |
"Use this shape:\n"
|
| 36 |
"{\n"
|
| 37 |
' "component": "short component name",\n'
|
|
|
|
| 45 |
' "name": "part name",\n'
|
| 46 |
' "role": "mechanical role",\n'
|
| 47 |
' "geometry": {"shape": "box", "size": [1, 1, 1], "position": [0, 0, 0]},\n'
|
| 48 |
+
' "motion": {"type": "static"},\n'
|
| 49 |
+
' "annotation": {"point": [0.5, 0.5], "label": "visible label", "note": "short visible clue"}\n'
|
| 50 |
" }\n"
|
| 51 |
" ]\n"
|
| 52 |
"}\n\n"
|
snap2sim/schema.py
CHANGED
|
@@ -35,6 +35,11 @@ EXAMPLE_ANALYSIS: dict[str, Any] = {
|
|
| 35 |
"color": "steel",
|
| 36 |
},
|
| 37 |
"motion": {"type": "static"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
},
|
| 39 |
{
|
| 40 |
"id": "ratchet_gear",
|
|
@@ -53,6 +58,11 @@ EXAMPLE_ANALYSIS: dict[str, Any] = {
|
|
| 53 |
"speed": 0.95,
|
| 54 |
"phase": 0,
|
| 55 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
},
|
| 57 |
{
|
| 58 |
"id": "pawl",
|
|
@@ -72,6 +82,11 @@ EXAMPLE_ANALYSIS: dict[str, Any] = {
|
|
| 72 |
"speed": 3.2,
|
| 73 |
"phase": 0.6,
|
| 74 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 75 |
},
|
| 76 |
{
|
| 77 |
"id": "selector",
|
|
@@ -84,6 +99,11 @@ EXAMPLE_ANALYSIS: dict[str, Any] = {
|
|
| 84 |
"color": "orange",
|
| 85 |
},
|
| 86 |
"motion": {"type": "static"},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
},
|
| 88 |
],
|
| 89 |
}
|
|
@@ -95,6 +115,7 @@ ANALYSIS_SCHEMA: dict[str, Any] = {
|
|
| 95 |
"properties": {
|
| 96 |
"component": {"type": "string"},
|
| 97 |
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
|
|
|
| 98 |
"summary": {"type": "string"},
|
| 99 |
"trigger": {"type": "string"},
|
| 100 |
"motion_sequence": {"type": "array", "items": {"type": "string"}},
|
|
@@ -102,7 +123,7 @@ ANALYSIS_SCHEMA: dict[str, Any] = {
|
|
| 102 |
"type": "array",
|
| 103 |
"items": {
|
| 104 |
"type": "object",
|
| 105 |
-
"required": ["id", "name", "role"
|
| 106 |
"properties": {
|
| 107 |
"id": {"type": "string"},
|
| 108 |
"name": {"type": "string"},
|
|
@@ -162,6 +183,26 @@ ANALYSIS_SCHEMA: dict[str, Any] = {
|
|
| 162 |
},
|
| 163 |
},
|
| 164 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
},
|
| 166 |
},
|
| 167 |
},
|
|
@@ -171,6 +212,27 @@ ANALYSIS_SCHEMA: dict[str, Any] = {
|
|
| 171 |
|
| 172 |
_SHAPES = {"box", "cylinder", "sphere", "gear", "rod"}
|
| 173 |
_MOTIONS = {"rotate", "translate", "oscillate", "static"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
|
| 176 |
def validate_analysis(payload: dict[str, Any]) -> dict[str, Any]:
|
|
@@ -181,6 +243,8 @@ def validate_analysis(payload: dict[str, Any]) -> dict[str, Any]:
|
|
| 181 |
for key in ["component", "summary", "trigger"]:
|
| 182 |
_require_string(payload, key, key)
|
| 183 |
_require_string_list(payload, "motion_sequence", "motion_sequence")
|
|
|
|
|
|
|
| 184 |
|
| 185 |
parts = payload.get("parts")
|
| 186 |
if not isinstance(parts, list) or not parts:
|
|
@@ -193,32 +257,56 @@ def validate_analysis(payload: dict[str, Any]) -> dict[str, Any]:
|
|
| 193 |
for key in ["id", "name", "role"]:
|
| 194 |
_require_string(part, key, f"{path}.{key}")
|
| 195 |
|
|
|
|
| 196 |
geometry = part.get("geometry")
|
| 197 |
-
if
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 208 |
|
| 209 |
motion = part.get("motion")
|
| 210 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
raise ValueError(f"Invalid analysis payload at {path}.motion: expected an object")
|
| 212 |
-
motion_type = motion.get("type")
|
| 213 |
-
if motion_type not in _MOTIONS:
|
| 214 |
-
raise ValueError(f"Invalid analysis payload at {path}.motion.type: unsupported motion")
|
| 215 |
-
if "axis" in motion:
|
| 216 |
-
_require_number_list(motion, "axis", f"{path}.motion.axis", 3)
|
| 217 |
-
if "range" in motion:
|
| 218 |
-
_require_number_list(motion, "range", f"{path}.motion.range", 2)
|
| 219 |
-
for key in ["speed", "amplitude", "phase"]:
|
| 220 |
-
if key in motion and not _is_number(motion[key]):
|
| 221 |
-
raise ValueError(f"Invalid analysis payload at {path}.motion.{key}: expected a number")
|
| 222 |
|
| 223 |
return payload
|
| 224 |
|
|
@@ -240,5 +328,15 @@ def _require_number_list(payload: dict[str, Any], key: str, path: str, length: i
|
|
| 240 |
raise ValueError(f"Invalid analysis payload at {path}: expected {length} numbers")
|
| 241 |
|
| 242 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 243 |
def _is_number(value: Any) -> bool:
|
| 244 |
return isinstance(value, (int, float)) and not isinstance(value, bool)
|
|
|
|
| 35 |
"color": "steel",
|
| 36 |
},
|
| 37 |
"motion": {"type": "static"},
|
| 38 |
+
"annotation": {
|
| 39 |
+
"point": [0.5, 0.55],
|
| 40 |
+
"label": "head housing",
|
| 41 |
+
"note": "visible outer shell around the ratchet mechanism",
|
| 42 |
+
},
|
| 43 |
},
|
| 44 |
{
|
| 45 |
"id": "ratchet_gear",
|
|
|
|
| 58 |
"speed": 0.95,
|
| 59 |
"phase": 0,
|
| 60 |
},
|
| 61 |
+
"annotation": {
|
| 62 |
+
"point": [0.5, 0.48],
|
| 63 |
+
"label": "ratchet gear",
|
| 64 |
+
"note": "central rotating socket carrier",
|
| 65 |
+
},
|
| 66 |
},
|
| 67 |
{
|
| 68 |
"id": "pawl",
|
|
|
|
| 82 |
"speed": 3.2,
|
| 83 |
"phase": 0.6,
|
| 84 |
},
|
| 85 |
+
"annotation": {
|
| 86 |
+
"point": [0.67, 0.44],
|
| 87 |
+
"label": "spring pawl",
|
| 88 |
+
"note": "locking tooth that alternates grip and slip",
|
| 89 |
+
},
|
| 90 |
},
|
| 91 |
{
|
| 92 |
"id": "selector",
|
|
|
|
| 99 |
"color": "orange",
|
| 100 |
},
|
| 101 |
"motion": {"type": "static"},
|
| 102 |
+
"annotation": {
|
| 103 |
+
"point": [0.5, 0.28],
|
| 104 |
+
"label": "selector",
|
| 105 |
+
"note": "external control that reverses drive direction",
|
| 106 |
+
},
|
| 107 |
},
|
| 108 |
],
|
| 109 |
}
|
|
|
|
| 115 |
"properties": {
|
| 116 |
"component": {"type": "string"},
|
| 117 |
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
| 118 |
+
"render_mode": {"type": "string", "enum": ["three", "annotate", "unavailable"]},
|
| 119 |
"summary": {"type": "string"},
|
| 120 |
"trigger": {"type": "string"},
|
| 121 |
"motion_sequence": {"type": "array", "items": {"type": "string"}},
|
|
|
|
| 123 |
"type": "array",
|
| 124 |
"items": {
|
| 125 |
"type": "object",
|
| 126 |
+
"required": ["id", "name", "role"],
|
| 127 |
"properties": {
|
| 128 |
"id": {"type": "string"},
|
| 129 |
"name": {"type": "string"},
|
|
|
|
| 183 |
},
|
| 184 |
},
|
| 185 |
},
|
| 186 |
+
"annotation": {
|
| 187 |
+
"type": "object",
|
| 188 |
+
"required": ["point"],
|
| 189 |
+
"properties": {
|
| 190 |
+
"point": {
|
| 191 |
+
"type": "array",
|
| 192 |
+
"items": {"type": "number", "minimum": 0, "maximum": 1},
|
| 193 |
+
"minItems": 2,
|
| 194 |
+
"maxItems": 2,
|
| 195 |
+
},
|
| 196 |
+
"box": {
|
| 197 |
+
"type": "array",
|
| 198 |
+
"items": {"type": "number", "minimum": 0, "maximum": 1},
|
| 199 |
+
"minItems": 4,
|
| 200 |
+
"maxItems": 4,
|
| 201 |
+
},
|
| 202 |
+
"label": {"type": "string"},
|
| 203 |
+
"note": {"type": "string"},
|
| 204 |
+
},
|
| 205 |
+
},
|
| 206 |
},
|
| 207 |
},
|
| 208 |
},
|
|
|
|
| 212 |
|
| 213 |
_SHAPES = {"box", "cylinder", "sphere", "gear", "rod"}
|
| 214 |
_MOTIONS = {"rotate", "translate", "oscillate", "static"}
|
| 215 |
+
_RENDER_MODES = {"three", "annotate", "unavailable"}
|
| 216 |
+
|
| 217 |
+
|
| 218 |
+
def select_render_mode(analysis: dict[str, Any]) -> str:
|
| 219 |
+
"""Pick the safest renderer for a validated analysis payload."""
|
| 220 |
+
explicit_mode = analysis.get("render_mode")
|
| 221 |
+
if explicit_mode in _RENDER_MODES:
|
| 222 |
+
return str(explicit_mode)
|
| 223 |
+
|
| 224 |
+
parts = analysis.get("parts") if isinstance(analysis, dict) else []
|
| 225 |
+
has_geometry = any(isinstance(part, dict) and isinstance(part.get("geometry"), dict) for part in parts)
|
| 226 |
+
has_annotation = any(isinstance(part, dict) and isinstance(part.get("annotation"), dict) for part in parts)
|
| 227 |
+
confidence = analysis.get("confidence", 1)
|
| 228 |
+
low_confidence = _is_number(confidence) and float(confidence) < 0.5
|
| 229 |
+
if has_annotation and (low_confidence or not has_geometry):
|
| 230 |
+
return "annotate"
|
| 231 |
+
if has_geometry and not low_confidence:
|
| 232 |
+
return "three"
|
| 233 |
+
if has_annotation:
|
| 234 |
+
return "annotate"
|
| 235 |
+
return "unavailable"
|
| 236 |
|
| 237 |
|
| 238 |
def validate_analysis(payload: dict[str, Any]) -> dict[str, Any]:
|
|
|
|
| 243 |
for key in ["component", "summary", "trigger"]:
|
| 244 |
_require_string(payload, key, key)
|
| 245 |
_require_string_list(payload, "motion_sequence", "motion_sequence")
|
| 246 |
+
if "render_mode" in payload and payload["render_mode"] not in _RENDER_MODES:
|
| 247 |
+
raise ValueError("Invalid analysis payload at render_mode: unsupported mode")
|
| 248 |
|
| 249 |
parts = payload.get("parts")
|
| 250 |
if not isinstance(parts, list) or not parts:
|
|
|
|
| 257 |
for key in ["id", "name", "role"]:
|
| 258 |
_require_string(part, key, f"{path}.{key}")
|
| 259 |
|
| 260 |
+
has_geometry = False
|
| 261 |
geometry = part.get("geometry")
|
| 262 |
+
if geometry is not None:
|
| 263 |
+
if not isinstance(geometry, dict):
|
| 264 |
+
raise ValueError(f"Invalid analysis payload at {path}.geometry: expected an object")
|
| 265 |
+
shape = geometry.get("shape")
|
| 266 |
+
if shape not in _SHAPES:
|
| 267 |
+
raise ValueError(f"Invalid analysis payload at {path}.geometry.shape: unsupported shape")
|
| 268 |
+
_require_number_list(geometry, "size", f"{path}.geometry.size", 3)
|
| 269 |
+
_require_number_list(geometry, "position", f"{path}.geometry.position", 3)
|
| 270 |
+
if "rotation" in geometry:
|
| 271 |
+
_require_number_list(geometry, "rotation", f"{path}.geometry.rotation", 3)
|
| 272 |
+
if "teeth" in geometry and not isinstance(geometry["teeth"], int):
|
| 273 |
+
raise ValueError(f"Invalid analysis payload at {path}.geometry.teeth: expected an integer")
|
| 274 |
+
has_geometry = True
|
| 275 |
+
|
| 276 |
+
has_annotation = False
|
| 277 |
+
annotation = part.get("annotation")
|
| 278 |
+
if annotation is not None:
|
| 279 |
+
if not isinstance(annotation, dict):
|
| 280 |
+
raise ValueError(f"Invalid analysis payload at {path}.annotation: expected an object")
|
| 281 |
+
_require_unit_number_list(annotation, "point", f"{path}.annotation.point", 2)
|
| 282 |
+
if "box" in annotation:
|
| 283 |
+
_require_unit_number_list(annotation, "box", f"{path}.annotation.box", 4)
|
| 284 |
+
for key in ["label", "note"]:
|
| 285 |
+
if key in annotation and not isinstance(annotation[key], str):
|
| 286 |
+
raise ValueError(f"Invalid analysis payload at {path}.annotation.{key}: expected a string")
|
| 287 |
+
has_annotation = True
|
| 288 |
+
|
| 289 |
+
if not has_geometry and not has_annotation:
|
| 290 |
+
raise ValueError(
|
| 291 |
+
f"Invalid analysis payload at {path}: expected geometry or annotation"
|
| 292 |
+
)
|
| 293 |
|
| 294 |
motion = part.get("motion")
|
| 295 |
+
if motion is not None:
|
| 296 |
+
if not isinstance(motion, dict):
|
| 297 |
+
raise ValueError(f"Invalid analysis payload at {path}.motion: expected an object")
|
| 298 |
+
motion_type = motion.get("type")
|
| 299 |
+
if motion_type not in _MOTIONS:
|
| 300 |
+
raise ValueError(f"Invalid analysis payload at {path}.motion.type: unsupported motion")
|
| 301 |
+
if "axis" in motion:
|
| 302 |
+
_require_number_list(motion, "axis", f"{path}.motion.axis", 3)
|
| 303 |
+
if "range" in motion:
|
| 304 |
+
_require_number_list(motion, "range", f"{path}.motion.range", 2)
|
| 305 |
+
for key in ["speed", "amplitude", "phase"]:
|
| 306 |
+
if key in motion and not _is_number(motion[key]):
|
| 307 |
+
raise ValueError(f"Invalid analysis payload at {path}.motion.{key}: expected a number")
|
| 308 |
+
elif has_geometry:
|
| 309 |
raise ValueError(f"Invalid analysis payload at {path}.motion: expected an object")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
|
| 311 |
return payload
|
| 312 |
|
|
|
|
| 328 |
raise ValueError(f"Invalid analysis payload at {path}: expected {length} numbers")
|
| 329 |
|
| 330 |
|
| 331 |
+
def _require_unit_number_list(payload: dict[str, Any], key: str, path: str, length: int) -> None:
|
| 332 |
+
value = payload.get(key)
|
| 333 |
+
if (
|
| 334 |
+
not isinstance(value, list)
|
| 335 |
+
or len(value) != length
|
| 336 |
+
or not all(_is_number(item) and 0 <= item <= 1 for item in value)
|
| 337 |
+
):
|
| 338 |
+
raise ValueError(f"Invalid analysis payload at {path}: expected {length} numbers from 0 to 1")
|
| 339 |
+
|
| 340 |
+
|
| 341 |
def _is_number(value: Any) -> bool:
|
| 342 |
return isinstance(value, (int, float)) and not isinstance(value, bool)
|