Local face recognition with visitor profiles, unknown clustering, household presence integration, and privacy-first opt-in model. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
14 KiB
Visitor Recognition — Design Spec
Overview
Local face recognition with visitor logging, person profiles, and household presence integration. Opt-in only, all data stored locally, no cloud. Uses the face_recognition library (dlib-based) for 128-dimensional face embeddings with cosine similarity matching.
Privacy Model
- Face recognition is OFF by default (
enabled = false) - Enabled per-camera in config — only cameras the user explicitly chooses
- No faces stored until enabled
- All embeddings and crops stored locally (SQLite + filesystem)
- Labeling a face requires explicit user action with a consent acknowledgment in the UI
- "Forget person" button permanently deletes all embeddings, crops, and visit history for a person
- No face detection on interior cameras unless explicitly enabled by the user
- No automatic enrollment — faces are detected and clustered, but naming requires human action
Face Pipeline
Detection Flow
Person detected (YOLO, existing) on opt-in camera
→ Crop person region from frame
→ face_recognition.face_locations(crop) → face bounding boxes
→ face_recognition.face_encodings(crop, locations) → 128-d embeddings
→ Compare against all labeled embeddings (cosine distance)
→ Distance < threshold (0.6): KNOWN_VISITOR event
→ Distance >= threshold: compare against unknowns
→ Match unknown cluster: add embedding, update visit
→ No match: create new unknown profile
→ No face found in crop: skip (back of head, obscured, etc.)
New File: vigilar/detection/face.py
FaceRecognizer class:
class FaceRecognizer:
def __init__(self, match_threshold: float = 0.6):
self._threshold = match_threshold
self._known_encodings: list[np.ndarray] = []
self._known_profile_ids: list[int] = []
self.is_loaded = False
def load_profiles(self, engine: Engine) -> None:
"""Load all face embeddings from DB into memory for fast comparison."""
def identify(self, frame: np.ndarray) -> list[FaceResult]:
"""Detect faces in frame, return identification results."""
def add_encoding(self, profile_id: int, encoding: np.ndarray) -> None:
"""Add a new encoding to the in-memory index."""
FaceResult dataclass:
@dataclass
class FaceResult:
profile_id: int | None # None if new unknown
name: str | None # None if unknown
confidence: float # 1 - distance (higher = more confident)
face_crop: np.ndarray # cropped face image
bbox: tuple[int, int, int, int] # face location in frame
Integration Points
Camera worker: After YOLO detects a person on an opt-in camera, pass the frame to FaceRecognizer.identify(). This runs after the existing person detection, not instead of it. Only runs on cameras listed in visitors.cameras config.
Event processor: Handle KNOWN_VISITOR, UNKNOWN_VISITOR, VISITOR_DEPARTED events. Insert/update visit records in DB.
Throttling: Face recognition runs at most once per 5 seconds per camera (more expensive than YOLO, and faces don't change that fast). Reuses the detection throttling pattern from the pet pipeline.
Database
New Table: face_profiles
| Column | Type | Notes |
|---|---|---|
| id | Integer PK | Autoincrement |
| name | String | NULL for unknowns, set when labeled |
| is_household | Integer NOT NULL | 1 = linked to presence member, default 0 |
| presence_member | String | Name from presence config (nullable) |
| primary_photo_path | String | Best face crop for display |
| visit_count | Integer NOT NULL | Total visits, default 0 |
| first_seen_at | Float NOT NULL | Timestamp |
| last_seen_at | Float NOT NULL | Timestamp |
| ignored | Integer NOT NULL | 1 = hidden from UI, default 0 |
| created_at | Float NOT NULL | Timestamp |
Index: idx_face_profiles_name on (name) where name IS NOT NULL
New Table: face_embeddings
| Column | Type | Notes |
|---|---|---|
| id | Integer PK | Autoincrement |
| profile_id | Integer NOT NULL | FK to face_profiles |
| embedding | Blob NOT NULL | 128 float32 values (512 bytes) |
| crop_path | String | Face crop image path |
| camera_id | String NOT NULL | Where captured |
| captured_at | Float NOT NULL | Timestamp |
Index: idx_face_embeddings_profile on (profile_id)
New Table: visits
| Column | Type | Notes |
|---|---|---|
| id | Integer PK | Autoincrement |
| profile_id | Integer NOT NULL | FK to face_profiles |
| camera_id | String NOT NULL | Which camera |
| arrived_at | Float NOT NULL | First detection timestamp |
| departed_at | Float | Last detection + timeout (NULL if still present) |
| duration_s | Float | Visit duration in seconds |
| event_id | Integer | Linked event |
Index: idx_visits_profile_ts on (profile_id, arrived_at DESC)
Index: idx_visits_ts on (arrived_at DESC)
Unknown Face Clustering
When a face doesn't match any labeled profile:
- Compare the embedding against all unknown (unlabeled) profiles' embeddings
- If cosine distance < threshold (0.6) to an existing unknown cluster: add the embedding to that profile, increment visit count, update last_seen
- If no match to any unknown: create a new unknown profile with this as the first embedding
This automatically groups repeat visitors. "Unknown #4 has visited 3 times" makes labeling easier — the user sees clusters, not individual face crops.
Embedding Storage Limit
Store up to 10 embeddings per profile. Beyond 10, keep the 10 with highest quality (largest face crop area as proxy for quality). This bounds storage and keeps the comparison set manageable.
Visit Tracking
Visit State Machine
No face detected → person detection + face match → ARRIVED
→ ongoing detections within departure_timeout_s → PRESENT (extend visit)
→ no detection for departure_timeout_s → DEPARTED
Managed per-profile per-camera. A person can be "visiting" on multiple cameras simultaneously (they walk between cameras).
Departure Detection
A background timer in the event processor checks every 60 seconds for active visits where departed_at IS NULL and last_seen_at < now - departure_timeout_s. Marks them as departed and logs VISITOR_DEPARTED event.
Event Types
Add to EventType enum:
| Event Type | Severity | When |
|---|---|---|
KNOWN_VISITOR |
INFO | Recognized labeled face detected |
UNKNOWN_VISITOR |
INFO (or WARNING if visits > threshold) | Unrecognized face detected |
VISITOR_DEPARTED |
INFO | Visitor no longer detected (logged only, no push) |
Alert Integration
KNOWN_VISITOR: default actionquiet_login alert profiles (friends arriving is not an alert). User can override per-profile.UNKNOWN_VISITOR: default actionrecord_onlyfor first visit,push_and_recordwhen same unknown exceedsunknown_alert_threshold(default 3 visits). This surfaces repeat unknowns without spamming on every stranger.VISITOR_DEPARTED: always logged, never pushed.
New detection_type values for alert profile rules: known_visitor, unknown_visitor.
Household Integration
Linking Faces to Presence Members
In the visitor settings UI, users can link a face profile to a presence member:
Link profile to: [-- Select --]
[Aaron]
[Other family member]
When linked:
face_profiles.is_household = 1face_profiles.presence_member = "Aaron"- The visitor dashboard shows face + phone presence side by side
- Presence events gain supplementary face confirmation data
Dual Confirmation Display
On the visitor dashboard, household members show:
Aaron
📱 Phone: HOME since 2:12 PM
📷 Face: Confirmed Front Entrance 2:14 PM
This is display-only — face confirmation does NOT change the presence logic. Phone ping remains authoritative for arm/disarm decisions. Face is supplementary confirmation that's useful for the audit log and daily digest.
Face-Enhanced Presence Events
When a household member's face is detected:
- The
PET_DETECTED-style event includesface_confirmed: truein the payload - The daily digest can report: "Aaron arrived at 2:12 PM (face confirmed)"
- No behavioral change — just richer data in the event stream
Configuration
[visitors]
enabled = false # master switch, off by default
match_threshold = 0.6 # cosine distance (lower = stricter)
cameras = [] # opt-in cameras (empty = none, even if enabled)
unknown_alert_threshold = 3 # alert after N visits from same unknown
departure_timeout_s = 300 # 5 min no detection = departed
max_embeddings_per_profile = 10 # cap storage per person
face_crop_dir = "/var/vigilar/faces" # where face crops are stored
Config model:
class VisitorsConfig(BaseModel):
enabled: bool = False
match_threshold: float = 0.6
cameras: list[str] = Field(default_factory=list)
unknown_alert_threshold: int = 3
departure_timeout_s: int = 300
max_embeddings_per_profile: int = 10
face_crop_dir: str = "/var/vigilar/faces"
Add to VigilarConfig as visitors: VisitorsConfig.
Web Blueprint
New File: vigilar/web/blueprints/visitors.py
Routes:
| Route | Method | Purpose |
|---|---|---|
/visitors/ |
GET | Visitor dashboard page |
/visitors/api/profiles |
GET | All profiles with visit stats (filterable: known/unknown/household/ignored) |
/visitors/api/visits |
GET | Visit log (filterable by profile_id, camera_id, date range, limit with 500 cap) |
/visitors/<id> |
GET | Profile detail page (visit history, face crops, stats) |
/visitors/<id>/label |
POST | Label an unknown: {name, consent: true}. Consent required. |
/visitors/<id>/link |
POST | Link to household member: {presence_member} |
/visitors/<id>/unlink |
POST | Unlink from household member |
/visitors/<id>/forget |
DELETE | Permanently delete profile, all embeddings, all crops, all visits |
/visitors/<id>/ignore |
POST | Hide from UI (set ignored=1), keep data |
/visitors/<id>/unignore |
POST | Unhide (set ignored=0) |
Label Endpoint Details
POST /visitors/<id>/label:
{
"name": "Bob",
"consent": true
}
consentmust betrueor request is rejected with 400- Updates
face_profiles.name - Returns the updated profile
Forget Endpoint Details
DELETE /visitors/<id>/forget:
- Delete all rows from
face_embeddingswhereprofile_id = id - Delete all rows from
visitswhereprofile_id = id - Delete all face crop image files for this profile
- Delete the
face_profilesrow - Remove from in-memory FaceRecognizer index
- Return
{"status": "forgotten"}
Template
New File: vigilar/web/templates/visitors/dashboard.html
Bootstrap 5 dark theme. Sections:
1. Household Members
- Cards for each linked profile
- Face thumbnail + name
- Phone presence status (HOME/AWAY with timestamp)
- Face confirmation status (last seen camera + time)
- Visit count + last visit
2. Known Visitors
- Grid of labeled profiles sorted by last visit
- Face thumbnail + name + visit count + last seen
- Click for profile detail page
3. Unknown Visitors
- Grid of unlabeled clusters sorted by visit count (descending)
- Face thumbnail + "Unknown #N" + visit count + cameras seen on
- "Label" button (opens inline form with name input + consent checkbox)
- "Ignore" button (dims the card, moves to bottom)
- "Forget" button (confirmation dialog, then permanent delete)
4. Recent Visits Log
- Chronological table: face thumbnail, name (or Unknown #N), camera, arrived, departed, duration
- Link to recording for each visit
- Paginated, loaded via fetch()
Profile Detail Page: vigilar/web/templates/visitors/profile.html
- Large face photo + name + edit button
- Stats: total visits, first seen, last seen, most common camera, usual time of day
- Visit history table
- Face crops gallery (all stored embeddings' crops)
- Household link controls (if applicable)
- "Forget this person" danger button
File Storage
/var/vigilar/faces/
{profile_id}/
primary.jpg — best face crop (display photo)
embed_001.jpg — face crop for embedding 1
embed_002.jpg — face crop for embedding 2
...
Dependencies
New Packages
face_recognition >= 1.3.0— face detection + 128-d embedding computation (depends on dlib)dlib >= 19.24— required by face_recognition (may need cmake for build)
Note: dlib compilation requires cmake and a C++ compiler. Document in installation instructions. Pre-built wheels are available for most platforms.
New Files
| File | Purpose |
|---|---|
vigilar/detection/face.py |
FaceRecognizer, FaceResult |
vigilar/web/blueprints/visitors.py |
Visitors web blueprint |
vigilar/web/templates/visitors/dashboard.html |
Visitor dashboard |
vigilar/web/templates/visitors/profile.html |
Profile detail page |
Modified Files
| File | Changes |
|---|---|
vigilar/constants.py |
Add KNOWN_VISITOR, UNKNOWN_VISITOR, VISITOR_DEPARTED to EventType |
vigilar/config.py |
Add VisitorsConfig model |
vigilar/storage/schema.py |
Add face_profiles, face_embeddings, visits tables |
vigilar/storage/queries.py |
Add face/visit CRUD + query functions |
vigilar/camera/worker.py |
Add face recognition to person detection path (opt-in cameras) |
vigilar/events/processor.py |
Handle visitor events, departure timer |
vigilar/web/app.py |
Register visitors blueprint |
pyproject.toml |
Add face_recognition dependency |
Out of Scope
- Face recognition on interior cameras (can be enabled by user but not default)
- Automatic face enrollment without user action
- Age/gender/emotion detection
- Face recognition on recorded video (live detection only)
- Multi-face tracking within a single frame (process faces independently)
- Face anti-spoofing (photo/screen detection) — YAGNI for home use
- Integration with external face databases
- GDPR compliance features (data export, retention policies) — this is a personal home system