Models¶
The data contract. Every module imports from here. Nothing else imports from anywhere else.
CameraConfig¶
@dataclass
class CameraConfig:
"""Configuration for a single RTSP camera."""
id: str
name: str
url: str
| Field | Type | Source |
|---|---|---|
id |
str |
TOML key under [cameras] — e.g., cam_01 |
name |
str |
cam_data.get("name", cam_id) — falls back to the ID |
url |
str |
Required. RTSP stream URL. Missing = ValueError at load time. |
No validation beyond existence. The URL is tested when RTSPStream tries to connect, not at parse time.
Settings¶
@dataclass
class Settings:
"""Global detection and runtime settings."""
detection_interval: float = 0.5
tolerance: float = 0.6
frame_scale: float = 0.25
screenshot_dir: str = "screenshots"
log_dir: str = "logs"
cooldown: int = 10
| Field | Type | Default | Validation |
|---|---|---|---|
detection_interval |
float |
0.5 |
Must be > 0. Seconds between detection passes per camera. |
tolerance |
float |
0.6 |
Must be in (0, 1]. Face distance threshold — lower is stricter. |
frame_scale |
float |
0.25 |
Must be in (0, 1]. Downscale factor before detection. 0.25 = 4× reduction. |
screenshot_dir |
str |
"screenshots" |
No validation. Created at AlertHandler init. |
log_dir |
str |
"logs" |
No validation. Created at AlertHandler init. |
cooldown |
int |
10 |
No explicit validation (implicitly must be ≥ 0). Seconds before re-alerting. |
__post_init__ Validation Rules¶
def __post_init__(self) -> None:
if not 0.0 < self.frame_scale <= 1.0:
raise ValueError(f"frame_scale must be in (0, 1], got {self.frame_scale}")
if not 0.0 < self.tolerance <= 1.0:
raise ValueError(f"tolerance must be in (0, 1], got {self.tolerance}")
if self.detection_interval <= 0:
raise ValueError(
f"detection_interval must be > 0, got {self.detection_interval}"
)
Fires at construction time. If you pass bad values from TOML, ValueError propagates up through load_cameras_config() and main.py exits with code 1.
WebhookConfig¶
@dataclass
class WebhookConfig:
"""Configuration for a single webhook endpoint."""
id: str
enabled: bool = True
url: str = ""
method: str = "POST"
headers: dict[str, str] = field(default_factory=dict)
body_template: str = ""
| Field | Type | Default | Notes |
|---|---|---|---|
id |
str |
— | TOML key under [webhooks]. |
enabled |
bool |
True |
Disabled webhooks are filtered out at load time. |
url |
str |
"" |
Skipped if empty, even if enabled = true. |
method |
str |
"POST" |
Uppercased at load time. |
headers |
dict[str, str] |
{} |
Merged with Content-Type: application/json at send time. |
body_template |
str |
"" |
JSON body with {placeholder} patterns. Not str.format() — regex substitution. |
Target¶
@dataclass
class Target:
"""A surveillance target with pre-computed face encodings."""
name: str
encodings: list[np.ndarray] = field(default_factory=list)
| Field | Type | Notes |
|---|---|---|
name |
str |
Display name from info.json, or the directory name as fallback. |
encodings |
list[np.ndarray] |
Each array is (128,) — a 128-D face encoding from face_recognition. Multiple images = multiple encodings. |
A target with zero encodings is discarded at load time. It never makes it to the detector.
MatchEvent¶
@dataclass
class MatchEvent:
"""A face recognition match event."""
target_name: str
camera_id: str
camera_name: str
confidence: float
timestamp: datetime
bbox: tuple[int, int, int, int] # (top, right, bottom, left) in original coords
frame: np.ndarray | None = field(default=None, repr=False)
| Field | Type | Notes |
|---|---|---|
target_name |
str |
Matched target's display name. |
camera_id |
str |
Camera identifier. |
camera_name |
str |
Human-readable camera name. |
confidence |
float |
1.0 - best_distance. Range: (0, 1]. Higher = better match. |
timestamp |
datetime |
UTC, set at detection time. |
bbox |
tuple[int, int, int, int] |
(top, right, bottom, left) — dlib's format. Not (x, y, w, h). |
frame |
np.ndarray \| None |
The original full-resolution BGR frame. repr=False to avoid dumping a 1080p numpy array into log output. Used downstream for screenshot annotation. |
The bbox Tuple¶
dlib and face_recognition return bounding boxes as (top, right, bottom, left) — the coordinates of the top-right and bottom-left corners. This is not the OpenCV convention of (x, y, w, h).
When passing to cv2.rectangle():
# Correct: cv2 uses (left, top) → (right, bottom)
cv2.rectangle(frame, (left, top), (right, bottom), color, thickness)
The top/left values may be smaller than bottom/right — this is normal. They're pixel coordinates, not dimensions.
frame Lifecycle¶
FaceDetector.detect()attachesframe(the original BGR array) to theMatchEvent.AlertHandler._save_screenshot()copies it and draws the bounding box + label.- The annotated copy is written to disk as JPEG.
- The original reference is discarded when the
MatchEventgoes out of scope.
The repr=False prevents loguru from attempting to serialize the numpy array when MatchEvent is logged. Without it, you'd get multi-megabyte log entries.