API and CLI
HTTP endpoints and their JSON shapes, the three fatal error states, the CLI entry points, and a troubleshooting table.
Helios serves the UI, the annotated feed, and its control API from one local FastAPI process.
HTTP endpoints
| Method + path | Purpose |
|---|---|
GET / | The single-page UI. |
GET /feed | MJPEG stream of pre-annotated frames. |
GET /api/status | Backend health, active concepts, counts, mode, zone, FPS. |
POST /api/chat | Natural-language concepts (presence) or a safety policy (PPE). |
POST /api/mode | Select the operating mode. |
POST /api/zone | Set the no-go rectangle, in normalized coordinates. |
POST /api/zone/clear | Remove the no-go rectangle. |
POST /api/tiling | Flip the live tiling toggle. |
POST /api/count/start | Begin cumulative counting. |
POST /api/count/stop | Freeze cumulative counting; totals are preserved. |
POST /api/count/reset | Zero cumulative totals. |
POST /api/shutdown | Orderly teardown, then exit the process. |
Cross-origin POSTs are rejected
A POST carrying an Origin header whose host differs from the request Host is refused with
403 FORBIDDEN_ORIGIN, so a drive-by page cannot flip the mode, policy, or zone on a running
instance. Same-origin browser fetches and tools that send no Origin are unaffected. This is a
lightweight guard, not authentication — Helios has none.
GET /feed
Returns multipart/x-mixed-replace; boundary=frame: a continuous MJPEG stream of JPEG frames with boxes, labels, overlays, and the mode banner already drawn in. Frames are paced to target_fps.
When no frame is available yet — or a fatal model error is set — the stream serves a dark placeholder frame rendered with the current status message, so the browser always gets a real image rather than a broken stream. An optional ?frames=N query parameter bounds the stream to N frames.
GET /api/status
{
"ready": true,
"error": null,
"message": null,
"active_concepts": ["person", "cup"],
"device": "cuda:0",
"fps": 29.4,
"live_counts": { "person": 2 },
"cumulative_counts": { "cup": 7 },
"counting": false,
"mode": "presence",
"required_ppe": [],
"zone": null,
"tiling_enabled": false
}error is null or one of the three fatal states below, with message carrying its user-facing text. mode is presence, ppe, or zone. zone is null or a four-element array of normalized coordinates. The UI polls this roughly every 3 seconds.
POST /api/chat
Request body, capped at 2000 characters:
{ "text": "anything someone could trip over" }In presence mode the response carries the mapped concepts plus the prompt history:
{
"concepts": ["bag", "cable", "shoe", "box", "chair"],
"history": [{ "prompt": "anything someone could trip over", "concepts": ["bag", "cable"] }]
}In PPE mode the same endpoint parses a safety policy and also returns the required-PPE keys:
{
"required_ppe": ["hard_hat", "safety_vest"],
"concepts": ["person", "hard hat", "safety vest"],
"history": [{ "prompt": "hard hat and safety vest", "concepts": ["person", "hard hat"] }]
}Failure responses:
| Status | error | When |
|---|---|---|
| 400 | INVALID_JSON | The body is not valid JSON. |
| 400 | TEXT_TOO_LONG | The prompt exceeds 2000 characters. |
| 409 | DRAWING_MODE | Chat is disabled in danger-zone mode — draw the region instead. |
| 502 | MODEL_UNAVAILABLE | Claude returned an unusable response. Never a literal-text fallback. |
| 503 | API_KEY_INVALID | The Anthropic API key is missing or rejected. |
POST /api/mode
{ "mode": "ppe" }Responds with the new mode and the vocabulary Helios staged for it:
{ "mode": "ppe", "active_concepts": ["person", "hard hat", "safety vest"] }An unrecognised value returns 400 BAD_MODE with the list of valid modes. Switching modes also resets the temporal smoothers.
POST /api/zone
Normalized [0,1] coordinates; each is clamped into range server-side.
{ "x1": 0.12, "y1": 0.4, "x2": 0.66, "y2": 0.95 }{ "zone": [0.12, 0.4, 0.66, 0.95] }| Status | error | When |
|---|---|---|
| 400 | INVALID_ZONE | The body is not an object, or a coordinate is not finite. |
| 400 | ZERO_AREA_ZONE | The rectangle has zero area. |
POST /api/zone/clear takes no body and responds with { "zone": null }.
POST /api/tiling
Requires a real JSON boolean; anything else is 400 INVALID_TILING.
{ "enabled": true }{ "tiling_enabled": true }The count endpoints
POST /api/count/start, /api/count/stop, and /api/count/reset all take no body and return the same shape:
{ "counting": true, "cumulative_counts": { "cup": 7 } }POST /api/shutdown
Backs the UI's ⏻ Stop button. Responds 200 first, then ends the open MJPEG streams, stops the capture loop (releasing the camera), and exits the process. This exists because long-lived MJPEG connections otherwise leave the server hanging on shutdown.
{ "status": "shutting down" }The three fatal error states
Helios has exactly three fatal states, each rendered as a full-pane error card with this exact text. There is no degraded path.
| State | User-facing message |
|---|---|
MODEL_UNAVAILABLE | GPU or detection model unavailable — check CUDA device and YOLOE weights. |
CAMERA_UNAVAILABLE | Camera not available — check the configured camera index. |
API_KEY_INVALID | Anthropic API key missing or invalid. |
MODEL_UNAVAILABLE also covers an unexpected fault inside the capture loop, so readers never keep seeing a frozen "ready" frame. API_KEY_INVALID blocks only the chat pane — the feed keeps running on the starter concepts.
CLI entry points
python -m app.capture --list # list cameras as 'index -> device name'
python -m app.vision --selftest # live GPU smoke test: load, bind, capture, infer
python -m app.vision --selftest --camera 700 # target a specific camera index
python -m app.vision --selftest --tiling # route the smoke test through the tiling pathThe self-test prints one line per frame with the device, model, tracker, tiling flag, frame shape, and detection count, then a line per detection with its label, confidence, box, and track_id. It is also the way to pre-warm the model-weight downloads.
Pure-logic modules ship with unit tests that need no GPU (pytest). The GPU and camera paths — live inference, the capture loop, the MJPEG feed — are verified by live smoke on the CUDA machine rather than mocked, so that no CPU stand-in creeps into a no-degraded-path design.
Troubleshooting
| Symptom or error card | Cause | Fix |
|---|---|---|
GPU or detection model unavailable (MODEL_UNAVAILABLE) | No CUDA GPU, wrong device, a CPU torch wheel, or corrupt/failed YOLOE weights | Confirm torch.cuda.is_available() is True — reinstall the CUDA torch wheel; check device in helios.toml; delete a corrupt *.pt / *.ts and re-download |
Camera not available (CAMERA_UNAVAILABLE) | Wrong camera_index, the phone/capture app is not streaming, or all-black frames on the wrong backend | Run python -m app.capture --list to find the index; start the virtual-camera preview first; try capture_backend = "dshow" or "msmf" |
Anthropic API key missing or invalid (API_KEY_INVALID) | ANTHROPIC_API_KEY unset or rejected | Set it in .env. The feed still runs on the starter concepts; only chat is blocked |
| Feed black but no error card | Virtual camera connected but not streaming | Open the phone-camera app preview; reconnect the USB cable |
| Cumulative totals climb too fast | Track-ID flicker reassigns new IDs to the same object | Raise track_buffer in the tracker YAML, or set tracker = "botsort.yaml" for occlusion-heavy scenes |
| PPE violation or zone intrusion strobes | Smoothing window too short for the frame rate | Raise smoothing_window / smoothing_min_ratio |
| Violation takes too long to flash (over ~2 s) | Smoothing window too long | Lower smoothing_window / smoothing_min_ratio |
| Safety glasses or other small PPE rarely detected | Small-object recall at the current imgsz | Raise imgsz (for example 640 to 896) and re-measure FPS; lean on hard hat and vest as the reliable items |
| Drawn no-go rectangle does not line up with the drag | Coordinates mapped before the feed image had natural dimensions | Wait for the feed to show a frame, then draw again |
PytorchStreamReader: failed finding central directory | Interrupted or corrupt weight download | Delete mobileclip_blt.ts (and any yoloe-*-seg.pt) and re-run to re-download |
| Slow first start | The one-time ~600 MB weight download | Pre-warm with python -m app.vision --selftest on good bandwidth |