Quickstart | Project Status | Features | Security | Swarm Architecture | Data Pipeline | API Reference | Observability | Testing | Troubleshooting
Argus is an AI-powered video analytics platform for real-time CCTV monitoring. It ingests concurrent RTSP, video-file, or webcam streams, runs YOLOv8 object detection on each frame, assigns persistent tracking IDs via a Kalman-filtered tracker, and evaluates zone-based rules (intrusion, loitering) to generate actionable security events.
The system employs a decentralised multi-agent swarm where autonomous agents (YOLO, Face, LPR) bid for compute resources through a Consortium Broker. The swarm is benchmarked against a linear baseline rather than assumed better — see Swarm Architecture for the measured numbers, and set ARGUS_NO_SWARM=1 to run the linear path.
Metaphor: In Greek mythology, Argus Panoptes was a hundred-eyed giant who served as a watchful guardian. This platform embodies that vigilance.
📖 File-by-file breakdown: FOLDER_STRUCTURE.md · Production gap analysis: PRODUCTION_ROADMAP.md
Current Development Status: Beta / Hardened Edge Prototype
| Architecture Component | Status | Implementation Details & Evidence |
|---|---|---|
| Detection & Tracking Pipeline | ✅ Working | YOLOv8n + DeepTracker (Kalman filter + Hungarian matching). CPU baseline: ~1.3–1.6 FPS/cam; GPU: ~30 FPS/cam. |
| Authentication & RBAC | ✅ Enforced | JWT + 3-role RBAC (admin, operator, viewer). Sliding-window rate limiter protects auth routes against brute-force. |
| Face Recognition & Biometrics | 🔒 Opt-in | Disabled by default. When enabled, embeddings are AES-256-GCM encrypted; new enrollments do not save source face crops. Older plaintext image files require explicit cleanup. |
| Cryptographic Audit Trail | ✅ Tamper-Evident | Append-only SHA-256 hash chaining with integrity verification (GET /api/v1/audit/integrity). |
| Database Architecture | ✅ Dual-Tier | SQLite (default zero-install edge) + PostgreSQL adapter (backend/database/postgres.py) with migration tooling. |
| CI & Security Workflows | ⚙️ Configured | GitHub Actions cover Python 3.11/3.13, frontend builds, security scans, and Docker image checks; badges above link to the latest workflow runs, when available. |
| Scientific Evaluation Set | The evaluation harness exists, but this repository does not include a public labeled ground-truth dataset yet. | |
| Swarm Agent Benchmark | 📊 One recorded run | On a bundled 40-frame CPU sample: 1.27 FPS linear vs 1.61 FPS swarm, with the same detection count; no ground-truth accuracy comparison or hardware metadata. |
| TLS / Production Exposure | Cleartext HTTP is development-only. Production requires fronting with Caddy/Nginx (recipes in infrastructure/). |
📖 Security Policy: SECURITY.md · Database Migration: docs/DATABASE_MIGRATION.md · Roadmap: PRODUCTION_ROADMAP.md
python argus.py startThat is the whole install. It works the same on Windows, macOS and Linux, and it is safe to re-run — everything below is skipped once it is already done:
- generates a strong
ARGUS_JWT_SECRETinto.env(first run only) - picks a runtime — Docker if the daemon is responding, otherwise a local
.venv(force either with--native/--docker) - installs dependencies, using CPU PyTorch wheels so the download is ~200 MB instead of ~2.5 GB of unusable CUDA payload
- creates the database and seeds the
adminuser - builds the dashboard and serves it from the API on one port
- waits for
/api/v1/health, then opens your browser
Argus is running
Dashboard http://localhost:8000
API docs http://localhost:8000/docs
Login admin (set with: python argus.py create-admin)
Stop it python argus.py stop
Prefer not to use a terminal? Double-click start.bat (Windows) or
start.command (macOS/Linux). stop.bat / stop.command shut it down.
Prefer make? make start and make stop wrap the same launcher, and
make docker-start / make docker-stop run it in a container (generating the
required signing key on first use). make on its own lists every target. The
Makefile is a thin wrapper, never a second implementation — Windows has no
make, so the logic stays in argus.py.
| Command | What it does |
|---|---|
python argus.py start |
Set everything up and run it |
python argus.py stop |
Stop everything (graceful, then forced) |
python argus.py status |
Show mode, port, PID and health |
python argus.py create-admin |
Create or update admin credentials (interactive prompt) |
python argus.py doctor |
Check this machine before installing |
python argus.py reset |
Delete the venv/build (--all also drops the DB) |
Useful flags: --port 9000, --native, --docker, --rebuild,
--reinstall, --no-browser, --host 127.0.0.1.
If something goes wrong, start prints the tail of .argus/backend.log and
tells you where the full log is. Run python argus.py doctor first if you want
to check Python, Node, Docker, disk space and port availability up front.
The launcher itself imports only the Python standard library — it has to run before any dependency exists. A test enforces that.
Prefer hot-reload and separate processes? The original flow still works.
- Python 3.9+ and Node.js 18+
- 4 GB RAM minimum
- RTSP camera streams or a local webcam for testing
python -m venv venv
# Windows: venv\Scripts\activate
# Linux/Mac: source venv/bin/activate
pip install -r requirements.txt
# Required: a signing key of at least 32 characters.
# Without it the server generates an ephemeral key, so every restart
# invalidates all issued tokens (fine for a demo, useless in production).
export ARGUS_JWT_SECRET="$(python -c 'import secrets;print(secrets.token_urlsafe(48))')"
python -m uvicorn backend.api.main:app --reload --host 127.0.0.1 --port 8000Argus authenticates against the Django auth_user table — there is no
second user store to drift out of sync. Create the first superuser:
# Create the Django auth tables and initialize a secure superuser:
python backend/scripts/run_admin.py --setup-only
# Set a password interactively before first login
python argus.py create-admin --username adminRoles are derived from Django group membership: a user in the operator
group gets the operator role, viewer gets viewer, and any superuser or staff
account is an admin. A user in no group defaults to viewer (least privilege).
cd frontend
npm install
npm run dev # Serves on http://localhost:3000The dashboard opens on a login screen. Tokens are held in sessionStorage and
refreshed transparently, so a 30-minute access-token expiry will not sign an
operator out mid-shift.
💡 Shortcut: python argus.py start does all of the above in one step and serves the built dashboard on port 8000 instead.
| Service | URL |
|---|---|
Dashboard (argus.py start) |
http://localhost:8000 |
Dashboard (npm run dev) |
http://localhost:3000 |
| ├ Cameras / Events / Analytics | / · /events · /analytics |
| └ Adaptive Learning | /learning |
| API banner (JSON) | http://localhost:8000/api |
| API Docs | http://localhost:8000/docs |
| Prometheus metrics | http://localhost:8000/metrics |
| WebSocket Stream | ws://localhost:8000/api/ws/stream/{camera_id}?token=<jwt> |
| Django Admin | http://localhost:8001/admin |
data/ and the model weights are gitignored, so after cloning you will need:
| Missing | How to get it |
|---|---|
backend/models/yolov8n.pt |
Downloaded automatically on first run by ultralytics, or fetch it manually |
data/argus.db |
Created automatically at startup; add users via Django admin (step 2) |
data/demo_clip.mp4 |
Included — it is the one exception, because the test suite needs it |
Regenerate the demo clip if you ever need to:
python backend/scripts/make_demo_clip.py --source path/to/street_photo.jpgIt reports how many objects YOLO finds in the first frame. Use a real photograph — the detector returns nothing on drawn shapes, so a synthetic clip looks identical to a broken pipeline.
No RTSP stream handy? Point a camera row at a local video file — the ingestion layer detects file sources, paces them to their native FPS, and loops on EOF:
curl -X POST http://localhost:8000/api/v1/cameras \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"name":"Demo","rtsp_url":"data/demo_clip.mp4","location":"Test"}'- Multi-Camera Ingestion – Concurrent RTSP / video-file / webcam streams with auto-reconnect, exponential backoff, native-FPS pacing for files, and EOF looping.
- YOLOv8 Detection – Person, vehicle, bicycle, and object detection with configurable confidence / IoU thresholds. Ships with
yolov8n.pt. - Object Tracking – Kalman-filtered motion prediction with two-stage association (IoU, then size-scaled centre distance) so identity survives the ~1s gap between processed frames on CPU.
- Zone-Based Rules – Polygon virtual zones (intrusion, loitering) and line
tripwires (
line_crossing), keyed on persistent track IDs. Scene-scoped rules (speed_violation,fall_detection,abandoned_object) need no zone and run on every camera. - Rule Honesty –
GET /api/v1/rules/statusreports, per rule, whether it is configured, implemented, and actually able to fire here. A rule whose prerequisite is missing (no ground-plane calibration, no pose keypoints) reports its blocker instead of quietly producing nothing — or worse, producing a fabricated number. - Observations Become Events – Perception findings (dwell, pacing, abandonment, occupancy anomalies, disappearances, scene changes) are promoted into the operator event feed with the evidence that justified them. Only grounded, actionable observations are promoted; bookkeeping kinds stay out.
- Event Management – SQLite-backed event store with sliding-window
deduplication (one event per subject per episode, not one per window),
snapshot capture, and MQTT publishing. Events follow an enforced lifecycle —
detected → open → acknowledged → resolved, orfalse_positive— recording who acted and when; an illegal transition is rejected, not silently written. - Real-Time WebSocket Streaming – Interleaved binary (JPEG) + JSON (detection metadata), authenticated at the handshake.
- Data Retention – Scheduled purge of events, snapshots, anomalies, plates, and audit rows on independent per-type schedules.
- Image Enhancement – CLAHE, denoising, sharpening, night vision, deblur, and HDR auto-enhancement.
- Cross-Camera Re-ID – Person re-identification across non-overlapping cameras using feature embeddings and cosine similarity.
Degrades gracefully when optional models are absent — the system logs the fallback at startup rather than failing:
| Feature | With optional dependency | Without it |
|---|---|---|
| License Plate Recognition | PaddleOCR text recognition | Basic OCR heuristics |
| Face Recognition | opencv-contrib LBPH matching |
Haar-cascade detection + histogram matching |
| Pose Estimation | MediaPipe 33-keypoint skeletons | Geometric fallback pose estimation |
Every API route requires a bearer token except four intentional exceptions:
GET /api/v1/health, GET /, POST /api/v1/auth/login, and
POST /api/v1/auth/refresh.
| Role | Can do |
|---|---|
| viewer | Read cameras, zones, events, analysis, anomalies, trackers, poses, metrics; watch live streams |
| operator | Everything a viewer can, plus create/update/delete zones, run cross-camera tracking, control the webcam, submit video for processing |
| admin | Everything, plus camera CRUD, the face database (register/list/delete), and the audit log |
Roles come from Django group membership and are re-read from the database on
every request. A token whose role claim has been tampered with is therefore
harmless — the claim is never trusted, only the user ID is.
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/auth/login |
Exchange username/password for an access + refresh token pair |
POST |
/api/v1/auth/refresh |
Exchange a refresh token for a new access token |
GET |
/api/v1/auth/me |
Current user identity and role |
GET |
/api/v1/audit |
Query the audit trail (admin only) |
TOKEN=$(curl -s -X POST http://localhost:8000/api/v1/auth/login \
-H 'Content-Type: application/json' \
-d '{"username":"admin","password":"changeme"}' | python -c "import sys,json;print(json.load(sys.stdin)['access_token'])")
curl http://localhost:8000/api/v1/cameras -H "Authorization: Bearer $TOKEN"- Brute-force lockout — 5 failed attempts locks an account for 300 s; the correct password is refused while locked (otherwise the lockout is decorative).
- Token separation — a refresh token is rejected where an access token is required, so a long-lived credential cannot be replayed as a short-lived one.
- WebSocket auth before
accept()— unauthenticated upgrades are refused at the handshake with a 403 rather than accepted and then closed. - Static mounts gated —
/snapshotsis only mounted when auth is disabled, because aStaticFilesmount bypasses route dependencies entirely. - Audit trail — every mutation and auth attempt is recorded with actor, role,
client IP, and outcome (including
denied). Writes never raise; a failed audit write must not take down the request path. - No plaintext secrets in config —
config.yamlsupports${VAR}interpolation and startup refuses to run with unresolved secret placeholders.
| Variable | Default | Purpose |
|---|---|---|
ARGUS_JWT_SECRET |
(ephemeral) | HS256 signing key, min 32 chars. Set this. |
ARGUS_ACCESS_TOKEN_MINUTES |
30 |
Access token lifetime |
ARGUS_REFRESH_TOKEN_DAYS |
7 |
Refresh token lifetime |
ARGUS_LOGIN_MAX_ATTEMPTS |
5 |
Failures before lockout |
ARGUS_LOGIN_LOCKOUT_SECONDS |
300 |
Lockout duration |
ARGUS_DISABLE_AUTH |
unset | Disables auth entirely — local development only |
ARGUS_CORS_ORIGINS |
localhost:3000/5173 | Comma-separated allowlist |
ARGUS_NO_SWARM |
unset | 1 forces the linear pipeline |
ARGUS_LOG_FORMAT |
text |
json for structured log ingestion |
ARGUS_LOG_LEVEL |
INFO |
Root log level |
| Agent | Role | Local Evolution |
|---|---|---|
| YOLO Detection Agent | Primary object detection | Optimises yolo_conf_threshold, iou_threshold, input_resolution_scale, tracker_matching_threshold via a genetic algorithm. |
| Face Recognition Agent | Face matching when persons detected | Evolves match_distance_threshold, min_face_size_px, track_timeout, frame_skip_cadence. |
| LPR Agent | License plate OCR when vehicles detected | Evolves segmentation_threshold, min_plate_height_px, resolution_downscale, detection_confidence, ocr_beam_width. |
| Consortium Broker | Resource auctioneer | Collects agent bids → resolves allocations → posts context to shared blackboard. |
| Logic Mutator | AST-constrained rule generation | Generates and evaluates internal Python filter rules with AST checks and restricted globals. This is not a sandbox for hostile code. |
| Evolutionary Engine | Cross-agent optimiser | Runs a self-contained genetic algorithm (elitism, crossover, Gaussian mutation) over the pipeline parameter space. No external GA library is used. |
40 frames of a 640×480 clip, CPU inference, each variant in a separate process so module-level singletons cannot leak state between runs:
| Mode | FPS | p50 latency | p95 latency | Detections/frame | Zero-detection frames |
|---|---|---|---|---|---|
| Linear baseline | 1.27 | 784.20 ms | 840.84 ms | 12.8 | 0 |
| Swarm | 1.61 | 612.78 ms | 750.53 ms | 12.8 | 0 |
In this recorded run only: the swarm processed 1.61 vs 1.27 FPS (+26.8%) and p50 latency was 612.78 vs 784.20 ms (−21.9%). Both modes returned the same mean count (12.8 detections/frame), but this benchmark has no ground-truth labels, so it does not establish equal detection accuracy. Hardware metadata was not captured; treat these figures as one sample, not a general performance claim.
Reproduce it yourself:
python tests/swarm_benchmark.py --frames 40 --json docs/swarm_benchmark_results.json
⚠️ The first honest run of this benchmark reported +45.2% FPS and −35.5% detections. The "speedup" was the broker starving the detector until it replayed a stale result forever. A performance number without a quality number next to it is not a result — see PRODUCTION_ROADMAP.md §5 for the full post-mortem.
- Docker Compose – Backend, frontend, Mosquitto MQTT, Qdrant vector DB, Kafka, Elasticsearch + Grafana.
- MediaMTX – Optional RTSP/HLS/WebRTC rebroadcast server.
- MQTT – Event publishing to
argus/events/{camera_id}/{rule_type}. - SQLite – Local persistent storage for cameras, zones, events, and behaviour profiles.
- Qdrant / Kafka – Optional vector storage and event streaming for external analytics.
┌──────────────┐ ┌─────────────────┐ ┌──────────────────────────────┐
│ RTSP Camera │───▶│ stream_ingestion │───▶│ ProcessingCoordinator │
│ / Webcam │ │ (cv2.VideoCapture│ │ (swarm OR fallback loop) │
└──────────────┘ │ + frame queue) │ │ │
└─────────────────┘ │ ┌────────────────────────┐ │
│ │ YOLO Agent (primary) │ │
│ │ → detections [dict] │ │
│ └───────────┬────────────┘ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ DeepTracker │ │
│ │ → Kalman filter │ │
│ │ → persistent track IDs│ │
│ └───────────┬────────────┘ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ LogicMutator │ │
│ │ →AST-constrained rule filter│ │
│ └───────────┬────────────┘ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ Consortium Broker │ │
│ │ → post context │ │
│ │ → resolve agent bids │ │
│ └───────────┬────────────┘ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ Face Agent (cond.) │ │
│ │ LPR Agent (cond.) │ │
│ └───────────┬────────────┘ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ PoseEstimator │ │
│ │ AnomalyDetector │ │
│ │ SpeedHeightAnalyzer │ │
│ └───────────┬────────────┘ │
│ ▼ │
│ ┌────────────────────────┐ │
│ │ RulesEngine │ │
│ │ → zone checks │ │
│ │ → event generation │ │
│ └───────────┬────────────┘ │
└──────────────┼───────────────┘
▼
┌─────────────────────────────────────────────┐
│ camera_analysis cache │
│ (detections, face, lpr, pose, anomalies) │
└──────────┬──────────────────────┬───────────┘
▼ ▼
┌──────────────────┐ ┌──────────────────────┐
│ EventStore (DB) │ │ WebSocket Stream │
│ + MQTTPublisher │ │ (JPEG binary + JSON)│
└──────────────────┘ └──────────┬───────────┘
▼
┌──────────────────────┐
│ LiveVideoPlayer.jsx │
│ → canvas overlays │
│ → bboxes + labels │
│ → zone polygons │
└──────────────────────┘
The connection is authenticated before the upgrade is accepted; an unauthenticated handshake is refused with HTTP 403. Two message types are interleaved:
- Binary message – JPEG-encoded video frame
- Text message – JSON detection metadata:
{
"camera_id": 2,
"detections": [
{
"track_id": 1,
"class": "person",
"confidence": 0.8251198530197144,
"bbox": { "x1": 495, "y1": 237, "x2": 590, "y2": 442 }
}
],
"timestamp": "2026-08-17T16:59:47.691052Z",
"timestamp_unix": 1786985987.6910653
}Field notes: the class label key is class (not class_name), bbox is an
object with x1/y1/x2/y2 (the frontend also accepts a 4-element array), and
timestamp is ISO 8601 UTC with timestamp_unix alongside for arithmetic.
The default screen is a command center: a live video wall with detection overlays, an alert feed, and — deliberately — the state of the things that silently fail.
- Video wall. Frames arrive over the documented WebSocket protocol and
detections are drawn client-side as corner brackets with track ID and
confidence. Brackets occlude far less of a subject than full rectangles,
which matters when the operator is trying to identify the person inside the
box. Overlay geometry accounts for letterboxing: the frame is rendered with
object-fit: contain, so scaling by the element size would smear every box across the black bars. - Liveness is measured, not assumed. A stalled camera holds its socket open
indefinitely, so an open connection proves nothing. Each tile runs its own
frame clock and degrades
LIVE → STALE → NO SIGNAL, rather than showing an old picture labelled live. - Failure never renders as zero. When a poll fails the panel keeps its last known value and marks itself stale. A surveillance dashboard that prints "0 events" after a failed request is actively dangerous — the operator reads it as "nothing happened".
- Delivery state is on screen. If no alert channel can deliver, a banner says so. Argus spent its whole history recording events and sending them nowhere; that failure mode is now impossible to miss.
- Evidence is fetched with credentials. Snapshots and clips are
role-protected, and neither
<img src>nor<video src>can carry a bearer token, so both are fetched through the authenticated client and played from object URLs.
Run it with python argus.py start (builds the UI and serves it from the API
on port 8000), or npm run dev in frontend/ for hot reload on port 3000.
98 registered routes: 92 under /api/v1; the rest are
system, streaming, WebSocket, and documentation endpoints. GET / is excluded:
it serves the dashboard when frontend/dist exists and a build hint when it
does not, so it is not part of the API surface.
Of the 92 /api/v1 operations, 89 require a token and 3 are public.
Everything requires Authorization: Bearer <token> except the entries marked
public below.
The Role column is the minimum role required.
Interactive docs: http://localhost:8000/docs
Geometry-only traffic layer (no biometrics enter it): digital twin, road-user classification and trajectories, wrong-way / near-miss / VRU safety analytics, traffic-flow statistics and signal-optimiser control.
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/api/v1/cityos/status |
viewer | Intersections overview + privacy posture |
GET |
/api/v1/cityos/twin |
viewer | Digital-twin snapshots for all intersections |
GET |
/api/v1/cityos/twin/{camera_id} |
viewer | Digital twin for one camera's intersection |
GET |
/api/v1/cityos/alerts |
viewer | Merged safety alert feed (kind filter) |
GET |
/api/v1/cityos/flow/{camera_id} |
viewer | Volume series, turning matrix, speed summary |
GET |
/api/v1/cityos/signal/{camera_id} |
viewer | Signal phase state + adaptive recommendation |
POST |
/api/v1/cityos/signal/{camera_id}/mode |
operator | Set mode: fixed / adaptive / manual |
POST |
/api/v1/cityos/signal/{camera_id}/phase |
operator | Manual override: force NS or EW green |
POST |
/api/v1/cityos/bind |
admin | Bind a camera to a named intersection |
| Method | Path | Role | Description |
|---|---|---|---|
POST |
/api/v1/auth/login |
public | Username/password → access + refresh tokens |
POST |
/api/v1/auth/refresh |
public | Refresh token → new access token |
GET |
/api/v1/auth/me |
viewer | Current identity and role |
GET |
/api/v1/audit |
admin | Query the audit trail (filter by user, action, outcome) |
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/api/v1/cameras |
viewer | List all cameras |
GET |
/api/v1/cameras/{id} |
viewer | Get one camera |
POST |
/api/v1/cameras |
admin | Add a camera |
PUT |
/api/v1/cameras/{id} |
admin | Update camera |
DELETE |
/api/v1/cameras/{id} |
admin | Remove camera |
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/api/v1/events |
viewer | Query events (camera_id, rule_type, priority, limit, offset) |
GET |
/api/v1/events/{id} |
viewer | Get single event |
GET |
/api/v1/events/stats |
viewer | Aggregate statistics (24h default) |
Note: the query/response field is
rule_type(intrusion,loitering), notevent_type.
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/api/v1/zones?camera_id={id} |
viewer | List zones (optional filter) |
POST |
/api/v1/zones |
operator | Create zone |
PUT |
/api/v1/zones/{id} |
operator | Update zone |
DELETE |
/api/v1/zones/{id} |
operator | Remove zone |
Note: the polygon field is
coordinates, notpolygon:{"camera_id":2,"name":"Dock","zone_type":"restricted","coordinates":[[0,0],[640,0],[640,480],[0,480]]}
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/api/v1/cross-camera/tracks |
operator | Active global tracks |
GET |
/api/v1/cross-camera/targets |
operator | Currently targeted persons |
POST |
/api/v1/cross-camera/target |
admin | Start targeted tracking |
DELETE |
/api/v1/cross-camera/target/{id} |
admin | Stop targeted tracking |
GET |
/api/v1/cross-camera/path/{id} |
operator | Movement path for a person |
GET |
/api/v1/cross-camera/predict/{id} |
operator | Predicted trajectory |
GET |
/api/v1/cross-camera/graph |
operator | Get camera adjacency graph |
POST |
/api/v1/cross-camera/graph |
admin | Set camera adjacency graph |
POST |
/api/v1/cross-camera/clear-old |
admin | Prune tracks older than N hours |
GET |
/api/v1/clusters |
viewer | DBSCAN trajectory clusters |
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/api/v1/analysis/{camera_id} |
viewer | Full analysis: detections, speed, height, face, LPR, pose, anomalies |
GET |
/api/v1/anomalies |
viewer | Recent anomaly events |
GET |
/api/v1/trackers |
viewer | Deep tracker status + active tracks |
GET |
/api/v1/poses |
viewer | Pose estimation statistics |
GET |
/api/v1/lpr |
operator | LPR system status |
POST |
/api/v1/enhance/analyze |
operator | Image enhancement analysis |
POST |
/api/v1/video/process |
operator | Process a video file through the pipeline |
GET |
/api/v1/faces |
admin | Registered known faces |
POST |
/api/v1/faces/register |
admin | Register a new face |
DELETE |
/api/v1/faces/{id} |
admin | Delete a registered face |
GET |
/api/v1/faces/status |
operator | Face recognition health |
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/ |
public | API banner: name, version, and docs link |
GET |
/api/v1/health |
public | Subsystem health, security posture, retention status |
GET |
/metrics |
public | Prometheus text exposition |
GET |
/api/v1/metrics |
viewer | Dashboard JSON metrics (CPU, RAM, per-camera FPS) |
GET |
/api/v1/stats/learning |
viewer | Adaptive learning metrics |
POST |
/api/v1/webcam/start |
operator | Start PC webcam |
GET |
/api/v1/webcam/status |
viewer | Webcam mode status |
POST |
/api/v1/webcam/stop |
operator | Stop webcam |
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/api/v1/perception/tracks |
viewer | Every tracked entity in the world model, with its accumulated attributes |
GET |
/api/v1/perception/tracks/{track_id} |
viewer | One entity: attributes, trajectory, relationships, observations |
GET |
/api/v1/perception/tracks/{track_id}/explain |
viewer | Evidence chain — measurements and inferences under separate keys |
GET |
/api/v1/perception/capabilities |
viewer | What can run here, measured cost, and why anything is unavailable |
GET |
/api/v1/perception/changes |
viewer | Per-camera change baselines and whether they are mature enough to judge |
GET |
/api/v1/perception/stats |
viewer | Throughput and measured per-stage cost |
/explain is the endpoint to reach for before acting on an alert. It never
returns a single confident narrative: measured holds only direct
observations, inferred and relationships hold conclusions drawn from them,
and each carries grounded (is there a measurement under this?) and
actionable (grounded and above the confidence floor). A conclusion that
rests only on other conclusions is reported as not actionable however
confident it looks.
/capabilities is deliberately blunt about what this host cannot do. On a
machine with no CUDA and no OCR engine it reports 12 of 18 capabilities
available, each unavailable one naming the reason and the remedy — for example
ocr explains that tesseract is missing and that text regions are still
being detected without it.
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/api/v1/rules/status |
viewer | Per rule: configured, implemented, and whether it can actually fire here — with the specific blocker when it cannot |
GET |
/api/v1/rules/calibration |
viewer | Per-camera ground-plane calibration, and what its absence disables |
GET |
/api/v1/observations/promotion |
viewer | How many perception observations became events, and which kinds are deliberately not promoted |
GET |
/api/v1/events/lifecycle |
viewer | The permitted event state machine, so clients need not hardcode it |
PATCH |
/api/v1/events/{event_id}/status |
operator | Advance an event; records the acting user. Illegal transitions return 400 |
GET |
/api/v1/notifications/status |
viewer | Which alert channels can actually deliver right now, plus policy and suppression counters |
POST |
/api/v1/notifications/test |
admin | Send a synthetic alert through every channel and report the real per-transport outcome |
GET |
/api/v1/evidence/status |
viewer | Pre-event ring-buffer occupancy per camera and clip counters |
GET |
/api/v1/events/{event_id}/clip |
viewer | Download the pre-event video clip; 404 with a reason if none, 410 once retention has removed it |
MQTTPublisher.publish_event() was fully written and mqtt.enabled was true
in config.yaml, but nothing in the codebase ever called it. Every event
Argus produced was stored in SQLite and sent nowhere, and no endpoint revealed
that. EventStore.create_event — the one choke point every event passes
through — now dispatches through a notification service with MQTT and webhook
transports, an alert policy (priority floor, allow/deny, midnight-wrapping
quiet hours, per-camera rate limit) and counters for everything it drops.
Channel availability is probed live, never inferred from the config flag:
with mqtt.enabled: true and no broker running, notifications/status
reports the channel as unavailable and gives the reason. A delivery that was
never attempted is never reported as sent, and a suppressed alert is counted
rather than swallowed. Delivery is best-effort and cannot break event
recording — a transport that raises is logged and reported, and the event is
still persisted.
A snapshot shows the instant a rule fired, not the approach. Argus keeps a
per-camera ring buffer of recent frames and exports an mp4 when a high-value
rule fires (evidence_clips.clip_rules); the path is attached to the event
metadata and served from /api/v1/events/{event_id}/clip.
Frames are buffered JPEG-encoded rather than raw, measured on 480p:
| per frame | 10 s @ 10 fps, one camera | |
|---|---|---|
| raw BGR | 0.88 MB | 88 MB |
| JPEG q=80 | 0.005–0.20 MB | 0.5–20 MB |
Raw buffering costs ~352 MB across four cameras — fatal beside the detection
model. Encoding costs ~2 ms/frame against a ~108 ms/frame YOLO budget. The
buffer is bounded in bytes as well as frames, because frame size varies
~40× with scene content, so a frame count alone is not a memory guarantee.
Clips expire under retention.clips_days with a clips_max_mb ceiling.
A rule declared enabled: true in config.yaml used to mean nothing on its
own — speed_violation, fall_detection and abandoned_object were all
advertised as enabled while no code evaluated them. They are implemented now,
and rules/status exists so the difference between configured and working
can never again be invisible.
Two of them refuse to fire rather than guess:
speed_violationneeds per-cameracamera_calibration. Multiplying pixel displacement by a global 0.05 m/px guess yields a number that looks like a measurement and is not one. Uncalibrated cameras report the blocker instead.fall_detectionneeds real pose keypoints. The bounding-box aspect-ratio fallback cannot distinguish a fall from crouching or lying down, and a high-priority medical alert must not rest on that.
| Method | Path | Role | Description |
|---|---|---|---|
GET |
/api/v1/memory/recall |
viewer | "What happened near the loading bay yesterday?" — free text over observations and their evidence, filterable by camera_id, kind, when, min_confidence |
GET |
/api/v1/memory/summary |
viewer | Plain-language digest of a period — the shift-handover answer |
GET |
/api/v1/memory/appearances/{camera_id}/{track_id}/similar |
viewer | "Where else has this person been?" — ranked by appearance, annotated with travel-time plausibility |
GET |
/api/v1/memory/stats |
viewer | What is stored, which vector backend is live, and its measured limits |
when accepts today, yesterday, last_hour, last_week, 24h.
Appearance matches are candidates for review, never identifications. The
descriptor compares clothing colour layout in three bands, so two people
dressed alike match strongly. Every result carries is_identification: false
and the caveat travels in the payload rather than living only in these docs.
Matches are also checked for physical plausibility — two cameras seeing
matching clothes at the same moment is evidence of two people, and that is
reported rather than hidden.
Vector search uses exact brute-force cosine in SQLite (measured: 5 ms at 1k
vectors, 37 ms at 10k, 207 ms at 50k). Qdrant is used automatically if a
server actually answers — never because config.yaml says enabled: true.
Past ~25 000 vectors /memory/stats warns you to run it.
| Endpoint | Type | Role | Description |
|---|---|---|---|
/api/ws/stream/{camera_id}?token=<jwt> |
WebSocket | viewer | Binary JPEG + JSON detections |
/api/stream/{camera_id} |
HTTP | viewer | MJPEG stream |
/api/mjpeg/stream/{camera_id} |
HTTP | viewer | MJPEG fallback stream |
/api/snapshots/{camera_id}/{filename} |
HTTP | viewer | Saved event snapshots |
Browsers cannot set headers on a WebSocket handshake, so the stream endpoint takes the token as a query parameter. The server validates it before accepting the connection.
Argus runs anywhere that can run a container. Full guide: docs/DEPLOYMENT.md.
cp .env.example .env
echo "ARGUS_JWT_SECRET=$(openssl rand -base64 48)" >> .env
echo "ARGUS_ADMIN_PASSWORD=choose-a-strong-one" >> .env
docker compose -f docker-compose.prod.yml up -d| Target | Config | Hosts |
|---|---|---|
| Docker / VPS / on-prem | docker-compose.prod.yml |
Everything |
| Render | render.yaml |
Everything |
| Fly.io | fly.toml |
Everything |
| Railway | railway.json |
Everything |
| GHCR image | .github/workflows/deploy.yml |
Everything |
| Vercel | vercel.json |
Dashboard only |
The container reads the platform's $PORT, creates the schema on first boot,
sets the admin password from ARGUS_ADMIN_PASSWORD without ever baking a
default into the image, and refuses to start without ARGUS_JWT_SECRET rather
than signing tokens with an ephemeral key that logs everyone out on restart.
Two things worth knowing before choosing a host.
Argus needs a disk. All mutable state — the SQLite database, snapshots,
evidence clips — lives under ARGUS_DATA_DIR. On a container platform that
must be a mounted volume. Mounting one at any other path persists nothing while
appearing to work until the first restart.
Vercel cannot host the backend. Not a configuration problem: the dependency
set is ~955 MB against a 500 MB function limit, serverless functions cannot
hold WebSocket connections open for video, there is no persistent process for
the retention thread, and the filesystem is ephemeral. vercel.json therefore
deploys the dashboard only; point it at an API hosted elsewhere by setting
VITE_API_ORIGIN at build time. Serving the UI from the API container is
simpler and has no CORS surface.
curl http://localhost:8000/metricsPrometheus text exposition — no client library dependency. Exposed metrics:
| Metric | Type | Labels |
|---|---|---|
argus_uptime_seconds |
gauge | — |
argus_process_cpu_percent |
gauge | — |
argus_process_memory_bytes |
gauge | — |
argus_camera_fps |
gauge | camera_id, name |
argus_camera_up |
gauge | camera_id, name |
argus_camera_queue_depth |
gauge | camera_id, name |
argus_cameras_total |
gauge | status |
argus_inference_latency_ms |
gauge | — |
argus_model_loaded |
gauge | — |
argus_detections_current |
gauge | camera_id |
argus_active_tracks |
gauge | — |
argus_events_24h_total |
gauge | rule |
argus_auth_enabled |
gauge | — |
argus_ephemeral_jwt_secret |
gauge | — |
/metrics is unauthenticated by design so a scraper needs no credentials — it
carries operational counters only, no frames, identities, or event contents.
Restrict it at the network layer if your threat model requires it.
Structured logging: set ARGUS_LOG_FORMAT=json to emit one JSON object per
log line, with any extra={...} fields merged in — so logs can be filtered by
camera_id instead of grepped.
{"timestamp": "2026-08-17T16:44:05", "level": "INFO", "service": "argus.ingestion", "message": "camera online", "camera_id": 2, "fps": 14.8}These metrics are not decoration. Within a minute of
/metricsgoing live it exposed two real bugs — 74 active tracks for a 12-person scene, and 377 loitering events from a single camera — that code review had missed entirely.
pytest -q # collects maintained test_*.py suites under tests/Check the latest GitHub Actions run for current pass status; suite counts and results change as tests evolve.
Every push and pull request runs these on Python 3.11 and 3.13 via
.github/workflows/ci.yml, which additionally
boots the real server to assert /health responds, an unauthenticated request
is refused with 401, and /metrics emits argus_ lines — plus a secret
scan, a gitignore-hygiene check, and a frontend build. See
CONTRIBUTING.md.
| Suite group | Files | Coverage |
|---|---|---|
| Maintained pytest suites | tests/test_*.py | Pipeline regression, API authorization, security hardening, CityOS, and sensor behavior. pytest.ini defines collection. |
The regression suite contains targeted cases for previously observed failures. The latest workflow run is the source of truth for whether the current tree passes.
test_api_security.py includes a static sweep that walks the live route table
and asserts every /api route outside the public allowlist carries an auth
dependency — so a newly added endpoint cannot silently ship unauthenticated.
python tests/swarm_benchmark.py --frames 40 --json docs/swarm_benchmark_results.jsonRuns each pipeline variant in a separate process (module-level singletons
would otherwise leak state between runs and bias the result). Options:
--clip, --frames, --json, --camera-id.
These are exploratory tools, not assertions — they predate the pytest suites and several require infrastructure that is not part of the default stack:
python backend/scripts/webcam_tester.py --camera 0 # needs a webcam
python tests/infrastructure_healthcheck.py # needs Kafka/ES/Qdrant
python tests/stream_simulator.py --count 4 # needs ffmpeg
python tests/buffer_monitor.py # runs until interrupted| Symptom | Likely Cause | Fix |
|---|---|---|
| Camera "Offline" | Invalid RTSP URL or network issue | Verify URL; check docker-compose logs backend |
| No events generated | Zones not defined, or confidence too high | Define zones; lower confidence_threshold in config.yaml |
| WebSocket connection failed | Backend not running / wrong port | Ensure uvicorn on :8000; check Vite proxy in vite.config.js |
| High CPU usage | Heavy YOLO model + no frame skipping | Use yolov8n.pt; enable evolutionary engine's frame skipping |
| MQTT not publishing | Broker offline / wrong address | mosquitto_sub -h localhost -t "argus/#" -v to verify |
| 401 on every API call | Auth is enabled and no token was sent | Log in first; the dashboard does this for you. For curl, see Authentication |
| 403 on a WebSocket | Token missing from the handshake | Use ?token=<jwt> — browsers cannot set headers on a WS upgrade |
| Tokens stop working after restart | ARGUS_JWT_SECRET unset, so an ephemeral key was generated |
Set ARGUS_JWT_SECRET to a fixed value ≥32 chars |
| 429 with "Try again in N seconds" | 5 failed logins locked the account | Wait out the lockout, or tune ARGUS_LOGIN_LOCKOUT_SECONDS |
| Login fails for a known-good user | Password not hashed by Django, or user inactive | Create/reset the user through Django admin |
Address already in use on :8000 |
A previous uvicorn is still holding the port | ss -lntp | grep :8000 then kill -9 <pid> |
Detections appear but track_id is always 0 |
Tracker silently failed and the exception was swallowed | Check logs for Error updating tracker; run pytest tests/test_regression.py -k Track |
| Event feed floods with duplicates | Old build without sliding-window dedup | Update; dedup now keys on track ID and slides while a condition persists |
| Zone POST returns 422 | Wrong field name | The polygon field is coordinates, not polygon |
| Frontend shows nothing but the login box | Backend unreachable through the Vite proxy | Confirm uvicorn on :8000; check vite.config.js proxy |
npm run dev/vite build dies with "Killed" or "The service was stopped" |
Out of memory — esbuild and a torch-loaded backend on the same small machine | Build with the backend stopped, or give the machine more RAM (~2 GB is not enough for both) |
argus/
├── README.md, FOLDER_STRUCTURE.md, PRODUCTION_ROADMAP.md, TODO.md,
│ INTEGRATION_TODO.md, CONTRIBUTING.md, CHANGELOG.md, LICENSE
├── .github/workflows/ci.yml # Lint, secret scan, tests (3.11/3.13), frontend build
├── pytest.ini # Test collection + markers
├── .dockerignore # Keeps .git/node_modules/data out of build context
├── requirements.txt # Core deps (incl. PyJWT)
├── requirements-optional.txt # Heavy/optional extras (django, kafka, qdrant…)
├── .env.example # Every ARGUS_* variable, documented
├── config/config.yaml # Agents, rules, retention; supports ${VAR}
├── Dockerfile # Production image (API + built dashboard)
├── docker/, docker-compose.yml # Development container builds
├── docker-compose.prod.yml # Production stack (single image + volume)
├── Makefile # make start / make stop shortcuts
├── render.yaml, fly.toml, railway.json # One-click platform blueprints
├── vercel.json # Static dashboard only - see docs/DEPLOYMENT.md
├── infrastructure/, mediamtx/, mosquitto/
├── data/ # argus.db, snapshots, known_faces, demo_clip.mp4
│
├── backend/
│ ├── api/
│ │ ├── main.py # FastAPI app, all routes, RBAC, audit middleware
│ │ ├── auth.py # JWT, roles, Django password verification, lockout
│ │ ├── observability.py # Prometheus exposition + JSON log formatter
│ │ ├── models.py # Pydantic schemas
│ │ ├── stream_routes.py # Snapshot & MJPEG (auth-gated)
│ │ └── stream_ws.py # WebSocket streaming (auth before accept)
│ ├── config/config.py # Config parser, ${VAR} interpolation, secret redaction
│ ├── database/db.py # SQLite schema + CRUD
│ ├── django_admin/ # Django admin over the same DB (auth_user lives here)
│ ├── models/yolov8n.pt # Detection weights
│ ├── services/
│ │ ├── core_engine/ # inference_engine, deep_tracker, yolo_detection_agent,
│ │ │ # consortium_broker, processing_coordinator,
│ │ │ # evolutionary_engine, logic_mutator
│ │ ├── vision/ # face_recognition, lpr, pose, image_enhancement
│ │ ├── analytics/ # person_reid, cross_camera_tracker, anomaly_detector
│ │ └── management/ # rules_engine, zone_manager, event_store, retention,
│ │ # audit_log, mqtt_publisher, stream_ingestion
│ └── scripts/ # webcam_tester, init_db, run_admin, create_test_events
│
├── frontend/
│ ├── src/App.jsx # Shell + auth gate (login vs dashboard)
│ ├── src/theme.js # Control-room design system; severity colours
│ ├── src/pages/CommandCenter.jsx # Video wall, alert feed, delivery + evidence state
│ ├── src/pages/Login.jsx # Login screen
│ ├── src/pages/ # CameraManagement, EventFeed, AnalyticsDashboard,
│ │ # AdaptiveLearningDashboard, MemoryExplorer
│ ├── src/components/CameraTile.jsx # Live tile: WS frames + letterbox-correct overlays
│ ├── src/components/ClipPlayer.jsx # Authenticated pre-event clip playback
│ ├── src/components/EvidenceImage.jsx # Authenticated snapshot fetch
│ ├── src/components/ui.jsx # Panels, metrics, status dots
│ └── src/services/api.js # Axios client, token store, transparent refresh
│
├── tests/
│ ├── test_regression.py # 23 pipeline-correctness tests
│ ├── test_api_security.py # 21 auth/RBAC/secret tests
│ ├── swarm_benchmark.py # Swarm vs linear A/B harness
│ └── (diagnostic scripts: buffer_monitor, stream_simulator, …)
│
└── docs/ # Architecture, testing guide, research notes,
# swarm_benchmark_results.json
MIT License — see LICENSE.
