Alert¶
Match found. Log it. Screenshot it. Notify. Move on.
AlertHandler¶
class AlertHandler:
"""Handles face match events: structured logging, screenshots, and webhooks.
Enforces a per-(target, camera) cooldown to avoid alert spam.
Webhook calls are fire-and-forget in daemon threads to never block detection."""
Constructor¶
| Action | Detail |
|---|---|
Creates screenshots/ dir |
Path(settings.screenshot_dir).mkdir(parents=True, exist_ok=True) |
Creates logs/ dir |
Path(settings.log_dir).mkdir(parents=True, exist_ok=True) |
| Initializes cooldown dict | _cooldowns: dict[tuple[str, str], float] — keys are (target_name, camera_id) |
| Initializes cooldown lock | _cooldown_lock: threading.Lock |
Calls _setup_logging() |
Reconfigures loguru globally |
Logging Setup¶
Two sinks. Both fire on every log call.
Sink 1: Colored Console (stderr)¶
logger.add(
sys.stderr,
level="INFO",
format=(
"<green>{time:HH:mm:ss}</green> | "
"<level>{level:<8}</level> | "
"<cyan>{name}</cyan>:<cyan>{function}</cyan> | "
"{message}"
),
)
Time format: HH:mm:ss — 24-hour, no date. Operator doesn't need the date in terminal output. The JSON file has full timestamps.
Sink 2: Structured JSON File¶
logger.add(
str(log_dir / "detections.json"),
serialize=True,
rotation="00:00",
retention="30 days",
compression="zip",
level="INFO",
)
| Parameter | Value | Meaning |
|---|---|---|
serialize |
True |
Each log entry is a JSON object with structured fields |
rotation |
"00:00" |
New file every midnight |
retention |
"30 days" |
Auto-delete after 30 days |
compression |
"zip" |
Old files compressed on rotation |
logger.remove() is called first — this nukes loguru's default handler to prevent duplicate console output.
handle()¶
The main entry point. Called by StreamManager for every match event.
Flow¶
1. Check cooldown → if active, return immediately
2. Update cooldown timestamp
3. Save annotated screenshot
4. Log the match (success level)
5. Fire webhooks (non-blocking)
Cooldown System¶
_cooldowns: dict[tuple[str, str], float] # (target_name, camera_id) → monotonic timestamp
_cooldown_lock: threading.Lock
How It Works¶
- Key:
(target_name, camera_id)— each person on each camera has its own cooldown. - Value:
time.monotonic()timestamp of last alert. - Check:
(time.monotonic() - last_alert) < settings.cooldown→ still on cooldown.
Why time.monotonic()? Wall clock time (time.time()) can jump — NTP sync, DST, manual adjustment. Monotonic time never goes backwards. A cooldown window is always exactly the configured duration.
Why per-(target, camera)? If "John" is seen on cam_01, we alert once and cool down for 10 seconds on cam_01. But if "John" appears on cam_02 during that window, that's a separate sighting and gets its own alert.
Thread Safety¶
_cooldown_lock protects the dict. Even though the main loop is single-threaded today, the lock exists for correctness if concurrent handle() calls ever happen.
_save_screenshot()¶
Filename Format¶
Example: 2025-01-15_14-30-22_123456_cam_01_John Doe.jpg
Timestamp uses %Y-%m-%d_%H-%M-%S_%f — microsecond precision to avoid collisions.
Annotation Drawing¶
- Copies the frame (never modifies the original).
- Draws a green bounding box (2px thickness) at the bbox coordinates.
- Draws a green filled rectangle for the label background.
- Draws black text on the green background:
"John Doe (87%)". - Writes as JPEG with quality 85 — good enough for evidence, small enough for storage.
Edge Case¶
If event.frame is None (shouldn't happen in normal operation), logs a warning and returns the path anyway. No crash.
Webhook Dispatch¶
`_fire_webhooks()¶
Spawns one daemon thread per webhook. Fire-and-forget — if a webhook fails, detection continues unaffected.
_send_webhook()¶
@staticmethod
def _send_webhook(
webhook: WebhookConfig,
event: MatchEvent,
screenshot_path: str,
) -> None:
Template Substitution¶
Uses regex, not str.format():
Why? str.format() conflicts with JSON. Your body_template is a JSON string with {} braces everywhere. str.format() tries to interpret JSON structure as Python placeholders. Regex avoids this entirely.
Available placeholders:
| Placeholder | Value |
|---|---|
{name} |
event.target_name |
{camera} |
event.camera_name |
{camera_id} |
event.camera_id |
{confidence} |
event.confidence (numeric, unquoted) |
{timestamp} |
event.timestamp.isoformat() |
{screenshot} |
Screenshot file path |
Numbers are not quoted in the substitution — confidence renders as 0.873, not "0.873". This preserves valid JSON when the template has numeric values.
Unknown placeholders raise KeyError — logged as an error, webhook skipped.
HTTP Request¶
resp = httpx.request(
method=webhook.method,
url=webhook.url,
content=body,
headers=headers,
timeout=10.0,
)
resp.raise_for_status()
| Error Type | Handling |
|---|---|
httpx.TimeoutException |
Logged as warning. No retry. |
httpx.HTTPStatusError |
Logged as error with status code. No retry. |
httpx.RequestError |
Logged as error. No retry. |
10-second timeout. A webhook that takes longer than 10 seconds is a broken webhook. No retries — the next match will try again.