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 + pathPurpose
GET /The single-page UI.
GET /feedMJPEG stream of pre-annotated frames.
GET /api/statusBackend health, active concepts, counts, mode, zone, FPS.
POST /api/chatNatural-language concepts (presence) or a safety policy (PPE).
POST /api/modeSelect the operating mode.
POST /api/zoneSet the no-go rectangle, in normalized coordinates.
POST /api/zone/clearRemove the no-go rectangle.
POST /api/tilingFlip the live tiling toggle.
POST /api/count/startBegin cumulative counting.
POST /api/count/stopFreeze cumulative counting; totals are preserved.
POST /api/count/resetZero cumulative totals.
POST /api/shutdownOrderly 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:

StatuserrorWhen
400INVALID_JSONThe body is not valid JSON.
400TEXT_TOO_LONGThe prompt exceeds 2000 characters.
409DRAWING_MODEChat is disabled in danger-zone mode — draw the region instead.
502MODEL_UNAVAILABLEClaude returned an unusable response. Never a literal-text fallback.
503API_KEY_INVALIDThe 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] }
StatuserrorWhen
400INVALID_ZONEThe body is not an object, or a coordinate is not finite.
400ZERO_AREA_ZONEThe 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.

StateUser-facing message
MODEL_UNAVAILABLEGPU or detection model unavailable — check CUDA device and YOLOE weights.
CAMERA_UNAVAILABLECamera not available — check the configured camera index.
API_KEY_INVALIDAnthropic 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 path

The 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 cardCauseFix
GPU or detection model unavailable (MODEL_UNAVAILABLE)No CUDA GPU, wrong device, a CPU torch wheel, or corrupt/failed YOLOE weightsConfirm 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 backendRun 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 rejectedSet it in .env. The feed still runs on the starter concepts; only chat is blocked
Feed black but no error cardVirtual camera connected but not streamingOpen the phone-camera app preview; reconnect the USB cable
Cumulative totals climb too fastTrack-ID flicker reassigns new IDs to the same objectRaise track_buffer in the tracker YAML, or set tracker = "botsort.yaml" for occlusion-heavy scenes
PPE violation or zone intrusion strobesSmoothing window too short for the frame rateRaise smoothing_window / smoothing_min_ratio
Violation takes too long to flash (over ~2 s)Smoothing window too longLower smoothing_window / smoothing_min_ratio
Safety glasses or other small PPE rarely detectedSmall-object recall at the current imgszRaise 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 dragCoordinates mapped before the feed image had natural dimensionsWait for the feed to show a frame, then draw again
PytorchStreamReader: failed finding central directoryInterrupted or corrupt weight downloadDelete mobileclip_blt.ts (and any yoloe-*-seg.pt) and re-run to re-download
Slow first startThe one-time ~600 MB weight downloadPre-warm with python -m app.vision --selftest on good bandwidth