API Reference¶
REST API for Iris. All application endpoints are JSON over HTTP and are mounted under /api; media endpoints (clips, thumbnails, HLS) return binary streams. The service binds to 127.0.0.1 only and is reached through an nginx cookie-SSO vhost that terminates TLS, authenticates the session and proxies to the app.
Routers (source: app/api/*.py):
| Resource | Prefix | File |
|---|---|---|
| Cameras | /api/cameras |
app/api/cameras.py |
| Live monitoring | /api/live |
app/api/live.py |
| Events / recordings | /api/events |
app/api/events.py |
| Identities (auto cross-camera) | /api/identities |
app/api/identities.py |
| People (manual face DB) | /api/people |
app/api/people.py |
| Face groups | /api/face-groups |
app/api/face_groups.py |
| System / introspection | /api/system |
app/api/system.py |
| Health | /health |
app/main.py |
Authentication¶
Defined in app/auth.py. Every /api/* route depends on require_auth (an alias of require_user), which accepts two credentials in priority order:
- Trusted SSO header — the nginx gateway injects
X-Email(configurable viasso_email_header; the username comes fromsso_user_header). The app trusts this header because it only binds to loopback and is reachable solely through nginx, which overrides any client-supplied value (anti-spoof). Presence of the header ⇒ the request is authenticated. - Bearer API key —
Authorization: Bearer <API_KEY>, for SSH-tunnel / CLI use that bypasses nginx. The token is compared in constant time (hmac.compare_digest) against the configuredAPI_KEY.
Fail-closed behaviour:
- When
auth_requiredistrue(the default) and neither credential is present, the request gets 401 (WWW-Authenticate: Bearer). A supplied-but-wrong bearer token yieldsInvalid API key.; a missing credential yieldsAuthentication required. - When
auth_requiredisfalse(local dev with no nginx and no key), the dependency degrades to an anonymous principal so the API is usable out of the box.
/health is the only unauthenticated route (used by an external monitoring probe).
Media endpoints (event clip/thumbnail, identity/sighting/face thumbnails) also work behind the bearer-key path so an HTML
<video>/<img>tag can fetch them through nginx carrying the SSO cookie.
Cameras — /api/cameras¶
CRUD over the cameras table. Mutations drive the per-camera WorkerManager: enabling/disabling a camera or changing its RTSP URL spawns, stops or restarts the camera's subprocess. Online/offline status is derived from the live worker heartbeat (published ~1 s) with a staleness fallback to the persisted row.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/cameras |
List all cameras with derived status. |
| POST | /api/cameras |
Create a camera (starts its worker immediately when enabled). 201. |
| GET | /api/cameras/{id} |
Fetch one camera. |
| PUT | /api/cameras/{id} |
Partial update. |
| DELETE | /api/cameras/{id} |
Delete camera + all derived data. 204. |
| GET | /api/cameras/{id}/status |
Live status: status, last_seen, fps, detector. |
| GET | /api/cameras/detect/classes |
Detector's supported COCO classes + the default trigger set (used by the camera form). |
Create / update body (all tuning fields optional on update):
| Field | Type | Notes |
|---|---|---|
name |
str | |
rtsp_url |
str | Write-only secret; never returned in clear (see masking below). |
enabled |
bool | |
detect_conf |
float 0–1 | Detection confidence threshold. |
pre_seconds / post_seconds |
int | Clip pre/post-roll (0–120 / 0–300). |
trigger_classes |
csv str | COCO classes that trigger recording, e.g. person,car,dog. Empty ⇒ person. |
detect_iou |
float 0–1 | NMS IoU. |
detect_imgsz |
int 128–1920 | Detector input size (px). |
detect_interval |
float 0–60 | Seconds between detections (0 = every frame). |
trigger_cooldown |
float 0–3600 | Min seconds between event triggers. |
min_trigger_frames |
int 1–100 | Consecutive detection frames before a trigger. |
rtsp_transport |
tcp|udp |
Validated. |
faces_enabled |
bool | Run face recognition on this camera. |
reid_enabled |
bool | Run cross-camera re-ID on this camera. |
Notable behaviour:
- Masked
rtsp_url— every response redacts embedded credentials (rtsp://user:pass@host/s→rtsp://***@host/s); the host is kept so operators can identify the camera. The raw URL stays server-side (the worker reads it straight from the DB). - Update echo handling — because the API only ever returns a masked URL, a blank or
***-containingrtsp_urlon update is treated as "keep the current one"; only a real new URL replaces the stored secret. - Worker lifecycle on update — worker is started on enable, stopped on disable, and restarted when the RTSP URL changes or any tuning field changes.
- RTSP repoint purges history — changing the RTSP URL purges this camera's prior events and their on-disk files (it is effectively a different physical camera).
- Delete is recursive — stops the worker and HLS session, removes on-disk artifacts (clips/thumbnails/segments/HLS/frame slot) before the FK cascade drops event rows, deletes camera-scoped rows with no FK cascade (face samples, presence segments, face/appearance exemplars), then drops any orphan identities left with no sightings (cross-camera identities still seen elsewhere survive) and unlinks them from events. Finally reloads the identity gallery + FAISS face index.
- Status semantics — disabled ⇒
offline; workererror⇒error; anonlineheartbeat older thanstatus_stale_seconds(default 30 s) ⇒offline.
Live monitoring — /api/live¶
Low-latency MJPEG / snapshot streaming of each worker's latest annotated JPEG frame, manual recording, and on-demand RTSP→HLS for live-with-sound. No transcoding happens here for MJPEG — the worker already encodes annotated frames (boxes drawn); this router only multiplexes bytes onto HTTP.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/live/{id}/snapshot |
Latest annotated frame as a single image/jpeg. |
| GET | /api/live/{id}/stream |
MJPEG (multipart/x-mixed-replace) stream of annotated frames. |
| POST | /api/live/{id}/record/start |
Begin a manual recording. |
| POST | /api/live/{id}/record/stop |
Finish a manual recording → persist a manual event + clip. |
| GET | /api/live/{id}/hls/index.m3u8 |
Start (idempotent) the HLS session and serve the playlist. |
| GET/POST | /api/live/{id}/hls/close |
Stop the camera's HLS session (player close / sendBeacon). |
| GET | /api/live/{id}/hls/{segment} |
Serve one .ts segment. |
Notable behaviour:
?fps=throttle — the MJPEG stream rate is capped bylive_mjpeg_fps(default 10).?fps=lets a caller request a lower rate (clamped to[1, cap]): the grid asks for a low rate across many tiles, a focused/fullscreen viewer asks for full rate. The generator only pushes a part when the frame actually changes, and stops when the client disconnects.- Graceful start-up —
snapshotand the MJPEG prime frame return a "no signal" placeholder JPEG (HTTP 200) when the worker has not yet produced a frame, so<img>elements don't break. 404 is returned only if the camera row doesn't exist. - Manual recording is stateless —
record/startreturns a server-trustedstarted_attimestamp; the client echoes it back torecord/stop(body{"started_at": "<iso>"}). Stop assembles a clip spanning[started_at, now]from the warm on-disk segment buffer (≈1 s pre/post-roll), bounded bysegment_retention_seconds, and persists it as amanualevent. Returns{event_id, clip, duration}. Invalidstarted_at⇒ 400. - HLS — playback is for live-with-sound (MJPEG carries no audio).
index.m3u8starts an ffmpeg RTSP→HLS session (returns 503 if HLS is disabled or at capacity), waits briefly for the playlist to appear, then serves it. Segment names are validated against^seg\d{5}\.ts$. nginx must run withproxy_buffering off(and the stream setsX-Accel-Buffering: no) for per-frame flushing.
Events / recordings — /api/events¶
Read/serve side of recordings. Events are created by the per-camera track-mode recorder (one event per presence) or by manual recording.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/events |
List/filter events, newest-first, paginated. |
| GET | /api/events/{id} |
Full event detail. |
| DELETE | /api/events/{id} |
Delete event row + clip + thumbnail files. 204. |
| POST | /api/events/bulk-delete |
Delete the given event ids (+ their files). |
| POST | /api/events/clear-all |
Delete ALL events (optionally filtered) — requires confirm. |
| GET | /api/events/{id}/clip |
Stream the recorded mp4 clip (HTTP Range supported). |
| GET | /api/events/{id}/thumbnail |
Event thumbnail JPEG. |
List filters (query): camera_id, person_id, from (ISO, inclusive), to (ISO, exclusive), label, limit (1–500, default 50), offset. Response: { total, items[] }.
Bulk-delete body: { "ids": [int, …] } (max_length 1000). Clear-all body: { "confirm": true, "camera_id"?, "label"? }; confirm is mandatory (400 otherwise), deletes in chunks of 200, and sightings referencing the events are SET NULL (they survive).
Notable behaviour:
- Range streaming —
/clipsupportsRange: bytes=start-end(and suffix rangesbytes=-N) for<video>seeking, returning 206 Partial Content withContent-Range; unsatisfiable ranges return 416; full requests stream the whole file in 1 MiB chunks (flat memory).Accept-Ranges: bytesis always advertised. - Path-traversal containment — every served file path is resolved against the data root and rejected (400) if it escapes (
os.path.commonpath). - 404s — missing event, or event with no clip/thumbnail, or file missing on disk.
Identities — /api/identities¶
The automatic, cross-camera person/object layer built by the re-ID pipeline. Identities are created automatically on sightings and corrected by operators after the fact. The ORM models are imported lazily; if they are absent the router returns 503.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/identities |
List identities, most-recently-seen first. |
| GET | /api/identities/analytics |
Aggregates for the People analytics dashboard. |
| GET | /api/identities/{id} |
One identity + recent inline sightings. |
| GET | /api/identities/{id}/sightings |
Paginated cross-camera/time sightings. |
| GET | /api/identities/{id}/events |
Recorded clips this identity appears in. |
| GET | /api/identities/{id}/faces |
Face samples captured for this identity. |
| GET | /api/identities/{id}/thumbnail |
Representative body-crop JPEG. |
| GET | /api/identities/sightings/{sid}/thumbnail |
One sighting's body-crop JPEG. |
| PUT | /api/identities/{id} |
Rename / annotate / confirm. |
| POST | /api/identities/merge |
Merge source_ids into target_id. |
| POST | /api/identities/{id}/split |
Split off sightings into a new identity. |
| DELETE | /api/identities/{id} |
Recursive delete. 204. |
| POST | /api/identities/bulk-delete |
Delete the given identity ids. |
| POST | /api/identities/clear-all |
Delete ALL identities (optionally one class) — requires confirm. |
List filters (query): named_only (operator-named only), min_sightings (hide sparse/provisional clusters), object_class (e.g. person/car/dog), limit (1–500, default 100), offset. Each item carries id, name, is_named, object_class, optional vehicle attrs (color/color_hex/make/vehicle_type), total_seconds, num_sightings, first_seen/last_seen, list of cameras, rep_thumb_url, face_thumb_url.
Detail (GET /{id}) — query recent (0–200, default 24) inlines that many recent sightings; also returns num_face_exemplars, num_appearance_exemplars, notes.
Analytics (GET /analytics) — query object_class (default person); returns a summary (totals, named/unnamed, created-today, seen-today, seen-7d, total sightings), by_camera (sightings, distinct people, avg dwell from presence segments), by_hour (last 24 h), and by_match_kind.
Rename body (PUT /{id}): { name?, notes?, is_named? }. Setting a non-null name marks is_named=true (freezes the maintenance auto-merge for that identity); is_named can be toggled explicitly.
Merge body: { "target_id": int, "source_ids": [int, …] } (1–1000 sources). Self-merge ids are dropped; all sources' sightings + face/appearance exemplars + denormalized events are reassigned to the target, then sources are deleted and counters/first-last-seen recomputed. A named source's name is preserved onto an unnamed target. Delegates to the re-ID core IdentityManager.merge when available, else a conservative DB-level fallback. Returns IdentityOpResult (ok, target_id, affected_ids, moved_sightings, detail).
Split body (POST /{id}/split): { sighting_ids?, auto?, new_name? }. Provide explicit sighting_ids to peel off, or auto=true to re-cluster the identity into two by face-priority. Explicit split has a DB-level fallback; auto split requires the re-ID core (returns 501 / 500 if unavailable). Without either parameter ⇒ 400.
Delete semantics (recursive):
- DB cascade removes the identity's sightings, face/appearance exemplars and presence segments.
- On-disk crops are purged: linked
FaceSamplecrops + their rows (no FK cascade), and the whole per-identity crop dirdata/identities/<id>/(where sighting body-thumbnails live). - Solo vs shared events — an event whose subjects are all being deleted is removed together with its clip + thumbnail files; an event shared with other surviving people is kept, only the deleted person's denormalized link is nulled.
- The identity gallery is reloaded afterward.
bulk-delete({ids}, max 1000) andclear-all({confirm, object_class?}, chunked by 200) apply the same purge logic.
People — /api/people¶
Manual face-enrollment DB. Enrollment runs the insightface (SCRFD + ArcFace) recognizer to produce a 512-d L2-normalized embedding, stores it as a FaceEmbedding blob (numpy frombuffer, little-endian f32 — not pickle) and adds it to the in-memory FAISS index. The DB is the single source of truth; the index is rebuilt from it on any change.
| Method | Path | Purpose |
|---|---|---|
| GET | /api/people |
List people with num_faces. |
| POST | /api/people |
Create a face-less person. 201. |
| GET | /api/people/{id} |
Fetch one person. |
| PUT | /api/people/{id} |
Update name / notes. |
| DELETE | /api/people/{id} |
Delete person + faces + images; rebuild index. 204. |
| POST | /api/people/bulk-delete |
Delete the given people (+ images). |
| POST | /api/people/clear-all |
Delete ALL people (+ images) — requires confirm. |
| POST | /api/people/{id}/faces |
Enroll a face from an uploaded photo. 201. |
| GET | /api/people/{id}/faces |
List a person's face embeddings. |
| GET | /api/people/{id}/faces/{fid}/image |
Serve the source enrollment photo. |
| DELETE | /api/people/{id}/faces/{fid} |
Remove one embedding + image; rebuild index. 204. |
Create/update body: { name, notes? }.
Enrollment (POST /{id}/faces) — multipart/form-data with file (one clear face). The largest detected face is embedded. Returns { embedding_id, faces_detected, image_path }. Errors: 415 unsupported content type, 400 empty upload / undecodable image, 413 too large (>8 MiB) or resolution too large (>50 MP decompression-bomb guard), 422 no face detected, 503 recognizer unavailable.
Notable behaviour:
- Index consistency — every delete (single / bulk / clear-all / face delete) detaches events (
person_id → NULL, keeping the denormalizedperson_namesnapshot), removes source images from disk, and rebuilds FAISS once from the DB.bulk-deletebody{ids}(max 1000);clear-allbody{confirm}(chunked by 200). - Containment — stored image paths are always relative (
data/faces/<id>/…); resolution is guarded against..-traversal outside the data dir.
Face groups — /api/face-groups¶
Unsupervised clustering of captured FaceSample rows into same-person groups by cosine similarity, computed on demand (so settings are just query params — no precomputed state to invalidate).
| Method | Path | Purpose |
|---|---|---|
| GET | /api/face-groups |
Cluster + list groups (largest first). |
| GET | /api/face-groups/samples/{id}/thumbnail |
Face-crop JPEG. |
| POST | /api/face-groups/enroll |
Turn a group into a known Person. |
| POST | /api/face-groups/label |
Set a label on the given samples. |
| POST | /api/face-groups/clips |
Recorded clips a face group appears in. |
| POST | /api/face-groups/samples/bulk-delete |
Drop the given captured faces (+ crops). |
| POST | /api/face-groups/samples/clear-all |
Delete ALL face samples (optionally one label) — requires confirm. |
| DELETE | /api/face-groups/samples/{id} |
Drop one captured face (+ crop). 204. |
Clustering params (query on GET ""): face_threshold (0–1, default 0.5, min cosine to link), clothing_weight (0–1, blend of clothing similarity), min_size (hide smaller groups), max_members (member thumbnails per group, default 40), max_samples (cap on samples clustered, ≤2000). Response: { total_samples, total_groups, settings, groups[] }; each group exposes a stable group_id (its representative sample id), size, majority label, cameras, first/last seen, representative thumb and member thumbs.
Notable behaviour:
- Enroll (
POST /enroll) — body{ sample_ids, name }. Creates aPerson, copies up to 12 highest-quality sample vectors intoFaceEmbeddingrows (same 512-d f32 layout copies directly), labels the group's samples with the name, and rebuilds the FAISS index so live matching uses the new person. Returns{ person_id, name, enrolled_faces }. - Label (
POST /label) — body{ sample_ids, label }; sets the label on those samples. Returns{ updated, label }. - Clips (
POST /clips) — body{ sample_ids }. Becausesighting.event_idis not reliably set, clips are resolved by camera + time overlap: a clip-event whose[ts-90s, end_ts+20s]window contains one of the group's face captures (or its linked identities' sightings). De-duplicated by event, newest-first; returns anEventListResponse.
System — /api/system¶
| Method | Path | Purpose |
|---|---|---|
| GET | /api/system |
Authenticated introspection: version, Python, the authenticated user (email/name/via), backend (detector, device, ONNX providers, faces-enabled), model paths (YOLO, insightface pack), face-index status + count, GPU memory (NVML, best-effort), and per-worker states. |
Health — /health¶
| Method | Path | Auth | Purpose |
|---|---|---|---|
| GET | /health |
none | Liveness probe for external monitoring: { status, version, gpu: {used_mb, total_mb}, workers[] }. |
The vanilla-JS SPA is served by StaticFiles(html=True) mounted at /; the API routers and /health take precedence over static paths.