Architecture¶
"He was the best and greatest of giants... he had a hundred eyes, and, wonderful to say, he had them all open." — Ovid, Metamorphoses
Design Philosophy¶
Argus is built on three principles:
-
Never crash. A dropped stream is not a fatal error — it's a reason to reconnect. A failed webhook is not an exception — it's a logged warning. Argus runs indefinitely without intervention.
-
Never block the eyes. Detection is the hot path. Everything else — logging, screenshots, webhooks — happens asynchronously. A dead webhook endpoint cannot stall face recognition.
-
Scale with cameras, not resources. Twenty cameras should not require twenty machines. Thread-per-camera with 512KB stacks, vectorized matching, and frame downscaling keep the footprint low.
System Overview¶
┌─────────────────────────────────────────────────────────────────────────────┐
│ main.py │
│ CLI parsing, component wiring, banner │
└────────────────────────────────────┬────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────────────────────┐
│ StreamManager │
│ Orchestrator — main thread, processing loop, signals │
└───┬─────────────┬─────────────┬─────────────┬─────────────┬─────────────────┘
│ │ │ │ │
▼ ▼ ▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐ ┌──────────────┐ ┌──────────┐
│RTSP │ │RTSP │ │RTSP │ │ FaceDetector │ │ Alert │
│Stream │ │Stream │ │Stream │ │ (dlib/face_ │ │ Handler │
│(cam_01)│ │(cam_02)│ │(cam_N) │ │ recognition)│ │ │
│ thread │ │ thread │ │ thread │ │ main thread │ │ main + │
│ 512KB │ │ 512KB │ │ 512KB │ │ _DETECT_LOCK│ │ daemon │
└────────┘ └────────┘ └────────┘ └──────────────┘ └──────────┘
│
┌───────┴───────┐
│ Display │
│ (optional) │
│ main thread │
│ one window/ │
│ camera │
└───────────────┘
Module Map¶
| Module | File | Role |
|---|---|---|
| Entry point | main.py |
CLI parsing, component wiring, banner output |
| Stream reader | argus/stream.py |
RTSPStream — one daemon thread per camera, FFmpeg-backed, auto-reconnect |
| Face detector | argus/detection.py |
FaceDetector — HOG detection, 128-D encoding, vectorized distance matching |
| Alert handler | argus/alert.py |
AlertHandler — cooldown enforcement, screenshot capture, webhook dispatch |
| Stream manager | argus/manager.py |
StreamManager — main processing loop, signal handling, GUI tick |
| Display | argus/display.py |
Display — optional OpenCV windows with bounding box overlays |
| Config loader | argus/config.py |
TOML parsing for cameras and webhooks |
| Data models | argus/models.py |
Dataclasses — CameraConfig, Settings, WebhookConfig, Target, MatchEvent |
| Target loader | argus/targets.py |
Scan targets/, compute face encodings, build flat encoding index |
The Processing Pipeline¶
Every detection pass follows this exact sequence:
RTSPStream._run()
│
├─► cv2.VideoCapture.open(url, CAP_FFMPEG)
│ FFmpeg backend, TCP transport, low-latency flags
│
├─► cap.read() loop (drains RTSP buffer at full speed)
│ │
│ └─► self._frame = frame (under self._lock)
│
▼
StreamManager._main_loop()
│
├─► Time-gate check: elapsed >= detection_interval?
│
├─► stream.latest_frame()
│ Returns frame.copy() under _lock ← snapshot semantics
│
├─► FaceDetector.detect(frame, camera_id, camera_name)
│ │
│ ├─► cv2.resize(frame, fx=0.25, fy=0.25)
│ │ Downscale for speed
│ │
│ ├─► cv2.cvtColor(BGR → RGB)
│ │
│ ├─► [acquire _DETECTION_LOCK]
│ │ face_recognition.face_locations(rgb_small, model="hog")
│ │ face_recognition.face_encodings(rgb_small, face_locations)
│ │ [release _DETECTION_LOCK]
│ │
│ ├─► face_recognition.face_distance(all_encodings, encoding)
│ │ Vectorized — all targets compared in one call
│ │
│ ├─► Filter: best_distance <= tolerance?
│ │
│ └─► Return list[MatchEvent] with scaled bboxes
│
├─► For each MatchEvent:
│ AlertHandler.handle(event)
│ │
│ ├─► Cooldown check: (target, camera) pair recent?
│ │ Skip if within cooldown window
│ │
│ ├─► _save_screenshot()
│ │ Copy frame, draw bbox + label, imwrite JPEG @ 85 quality
│ │
│ ├─► logger.success("MATCH: ...")
│ │ Bound structured data (person, camera, confidence, screenshot)
│ │ → stderr: colored human-readable format
│ │ → logs/detections.json: serialized JSON, rotated daily
│ │
│ └─► _fire_webhooks()
│ For each enabled webhook:
│ Thread(target=_send_webhook, daemon=True).start()
│ │
│ ├─► regex template substitution
│ ├─► httpx.request(method, url, content=body, timeout=10)
│ └─► log success/warning on failure
│
├─► Display.update(camera_id, frame, matches)
│ Draw bounding boxes, cv2.imshow()
│
└─► Display.tick()
cv2.waitKey(1), return False if 'q' pressed
Threading Model¶
Argus uses three classes of threads:
1. Stream Reader Threads (daemon)¶
- One per camera. Named
stream-{camera.id}. - 512KB stack. Set globally via
threading.stack_size(512 * 1024)before any thread is spawned. The default Python stack is 8MB — irrelevant for a thread that just reads frames and updates a buffer. - Owns the
cv2.VideoCaptureinstance. No sharing, no locks around the capture. - Writes frames to
self._frameunderself._lock— a lightweight per-streamthreading.Lock. - Reads frames at full RTSP speed to drain the buffer. The main thread consumes at
detection_intervalrate. This decoupling prevents RTSP buffer buildup. - Reconnects automatically on stream failure with exponential backoff (see below).
2. Main Thread (processing loop + GUI)¶
- Runs
StreamManager._main_loop(). This is the orchestrator — it iterates cameras, grabs frames, runs detection, dispatches alerts, and ticks the GUI. - Must be the main thread if GUI is enabled. OpenCV's
imshowandwaitKeyrequire the main thread on most platforms. - Handles signals.
SIGINT(Ctrl+C) andSIGTERMtrigger graceful shutdown. - 10ms sleep per loop iteration to prevent busy-waiting while staying responsive.
3. Webhook Threads (daemon)¶
- Spawned per webhook per match event. Each webhook fires in its own thread.
- Daemon threads — they die when the main process exits. No cleanup needed.
- Fire-and-forget. A slow or dead endpoint cannot stall the pipeline. The 10-second httpx timeout is the only constraint.
- No thread reuse. Each match spawns fresh threads. The overhead is negligible for a system that fires alerts at human-activity rates (not per-frame).
Thread Interaction Diagram¶
Main Thread
│
├──► stream.latest_frame() ──[lock]──► Stream Thread (_frame buffer)
│
├──► detector.detect() ──[lock]──► _DETECTION_LOCK (serializes dlib)
│
├──► alert_handler.handle()
│ │
│ ├──► _save_screenshot() (main thread, synchronous)
│ ├──► logger.success() (main thread, synchronous)
│ └──► Thread(_send_webhook) (spawns daemon thread)
│
└──► display.tick() (main thread, cv2.waitKey)
Synchronization¶
Three locks protect shared state. Each has a single, well-defined purpose:
_DETECTION_LOCK (module-level, detection.py)¶
Protects all face_recognition / dlib calls. The dlib library uses global singleton objects (detector, encoder) that are not thread-safe. Without this lock, concurrent face detection from multiple camera threads would corrupt dlib's internal state. Every call to face_recognition.face_locations() and face_recognition.face_encodings() is serialized behind this lock.
This is the bottleneck by design. Detection is CPU-bound and dlib is not thread-safe. Serializing it is cheaper than crashing.
_lock (per-stream, stream.py)¶
Protects the frame buffer (self._frame and self._has_frame). The stream reader thread writes; the main thread reads via latest_frame(). The read returns a copy (self._frame.copy()) — snapshot semantics. The main thread never holds a reference to the live buffer.
_cooldown_lock (per-AlertHandler, alert.py)¶
Protects the cooldown dictionary (self._cooldowns). The cooldown check-and-update is not atomic without this lock. Two threads detecting the same target on the same camera simultaneously could both pass the cooldown check and fire duplicate alerts. This lock prevents that race.
Memory Management¶
Thread Stack Size¶
Called once at import time, before any threads are spawned. This applies to all subsequent threads in the process. The default Python thread stack is 8MB — Argus reduces it to 512KB because stream reader threads don't need deep call stacks. They read frames, update a buffer, and reconnect on failure.
Impact at scale: 20 cameras × 512KB = 10MB of stack memory, versus 160MB at the default. This matters on VMs and containers with tight memory limits.
Frame Copying Strategy¶
The stream reader writes frames to self._frame under a lock. The consumer reads via latest_frame(), which returns self._frame.copy() under the same lock.
Why copy? The main thread may hold the frame reference for the duration of detection (HOG + encoding + matching) — potentially tens of milliseconds. If the stream reader overwrote self._frame during that window, the consumer would operate on a partially-written buffer. The copy guarantees a consistent snapshot.
Why not a queue? A queue would buffer multiple frames, increasing memory usage and latency. Argus only cares about the latest frame — older frames are stale. The lock-protected single-slot buffer is the minimal correct solution.
Reconnection Strategy¶
Stream failures are expected, not exceptional. Cameras go offline, networks drop, RTSP servers restart. Argus handles all of this without human intervention.
Exponential Backoff¶
Formula: delay = min(BASE × 2^failures, MAX)
| Consecutive Failures | Delay |
|---|---|
| 1 | 1s |
| 2 | 2s |
| 3 | 4s |
| 4 | 8s |
| 5 | 16s |
| 6 | 32s |
| 7+ | 60s (capped) |
The delay resets to 0 on successful connection. A stream that briefly drops reconnects in 1 second. A stream that stays down backs off to 60 seconds and stays there.
Consecutive Read Failures¶
Inside the read loop, if cap.read() returns False for 30 consecutive attempts, the stream is declared dead and reconnection is triggered. This threshold prevents rapid-fire failures from hammering the reconnection logic while still detecting genuine stream death quickly.
Interruptible Wait¶
The backoff wait uses self._stopped.wait(timeout=delay) — not time.sleep(). This means a shutdown signal (stop()) interrupts the wait immediately. No hanging during shutdown.
FFmpeg Options¶
Argus configures FFmpeg's RTSP transport globally via environment variable:
os.environ["OPENCV_FFMPEG_CAPTURE_OPTIONS"] = (
"rtsp_transport;tcp"
"|fflags;nobuffer"
"|flags;low_delay"
"|analyzeduration;1000000"
"|probesize;1000000"
"|stimeout;5000000"
)
| Option | Value | Purpose |
|---|---|---|
rtsp_transport |
tcp |
Force TCP transport. UDP loses packets on congested networks; TCP guarantees delivery. |
fflags |
nobuffer |
Disable FFmpeg's internal buffering. Read frames immediately as they arrive. |
flags |
low_delay |
Minimize decoder latency. Trades quality for speed — appropriate for surveillance. |
analyzeduration |
1000000 (1s) |
Limit stream analysis to 1 second. Prevents FFmpeg from spending minutes probing slow streams. |
probesize |
1000000 (~1MB) |
Limit probing data to 1MB. Same rationale — don't waste time analyzing huge chunks. |
stimeout |
5000000 (5s) |
Socket timeout. If no data arrives in 5 seconds, consider the connection dead. |
These options are set before any VideoCapture is created — they apply process-wide to all FFmpeg-backed captures.
Signal Handling¶
signal.signal(signal.SIGINT, self._signal_handler)
signal.signal(signal.SIGTERM, self._signal_handler)
Both SIGINT (Ctrl+C) and SIGTERM (kill, systemd stop) trigger the same handler: set self._running = False. The main loop checks this flag at the top of every iteration and exits cleanly.
Shutdown Sequence¶
- Signal received →
_running = False - Main loop exits → enters
finallyblock _shutdown()called → iterates all streams, callsstream.stop()on eachstream.stop()→ setsself._stoppedevent, which:- Interrupts any in-progress reconnection wait
- Causes the stream reader's
while not self._stopped.is_set()loop to exit - Display destroyed →
cv2.destroyAllWindows() - Log message →
"Argus stopped. All-seeing eyes closed."
All stream threads are daemons — they die with the process anyway. But the explicit stop() call ensures they exit promptly rather than waiting for the GC.
Error Handling Philosophy¶
Argus does not crash. Every failure mode has a recovery path:
| Failure | Response |
|---|---|
| Camera unreachable | Log warning, exponential backoff, retry forever |
| Stream drops mid-read | Log warning after 30 consecutive failures, reconnect |
| No target images loaded | Warning — detection still runs, identification disabled |
| Target image has no face | Warning — that image skipped, other images still used |
| Webhook endpoint down | Log warning, skip, try again on next match |
| Webhook timeout (10s) | Log warning, continue |
| Invalid webhook template | Log error with placeholder name, skip that webhook |
Bad info.json (malformed) |
Log error, skip that target |
| Missing image file | Log warning, skip that image |
| Config file missing | FileNotFoundError → log error, sys.exit(1) — this is the one hard failure, because running with no config is pointless |
| GUI unavailable | Not handled explicitly — OpenCV will raise if display is unavailable, which is acceptable for a GUI feature |
The only intentional exit codes are:
sys.exit(1)— configuration error (missing or invalidcameras.toml)KeyboardInterrupt— caught by the main loop, triggers clean shutdown
Everything else recovers.
Data Models¶
All data flows through typed dataclasses defined in argus/models.py:
CameraConfig¶
@dataclass
class CameraConfig:
id: str # TOML key (e.g., "cam_01")
name: str # Human-readable name (e.g., "Front Door")
url: str # RTSP stream URL
Settings¶
@dataclass
class Settings:
detection_interval: float = 0.5 # seconds between detection passes
tolerance: float = 0.6 # face distance threshold
frame_scale: float = 0.25 # downscale factor
screenshot_dir: str = "screenshots"
log_dir: str = "logs"
cooldown: int = 10 # seconds before re-alert
Validated in __post_init__ — frame_scale and tolerance must be in (0, 1], detection_interval must be > 0.
MatchEvent¶
@dataclass
class MatchEvent:
target_name: str # who was matched
camera_id: str # which camera
camera_name: str # human-readable camera name
confidence: float # 1.0 - distance (higher = better)
timestamp: datetime # UTC, ISO 8601
bbox: tuple[int, int, int, int] # (top, right, bottom, left) in original coords
frame: np.ndarray | None = None # full-res BGR frame (for screenshots)
The frame field is excluded from __repr__ to avoid dumping raw pixel data into logs.
Logging¶
Argus uses loguru with two sinks:
Console Sink (stderr)¶
Colored, human-readable, timestamped. Level: INFO and above.
Structured JSON Sink (logs/detections.json)¶
{
"text": "MATCH: 'John Doe' on 'Front Door' (confidence: 92.3%)",
"record": {
"time": {"timestamp": "2025-01-15T14:35:12.345Z"},
"level": {"name": "SUCCESS"},
"extra": {
"person": "John Doe",
"camera": "Front Door",
"camera_id": "cam_01",
"confidence": 0.923,
"screenshot": "screenshots/2025-01-15_14-35-12_345678_cam_01_John Doe.jpg"
}
}
}
Machine-parseable. Rotated daily at midnight. Retained for 30 days. Compressed to zip on rotation.
Argus is always watching. The question is whether you're watching back.