diff --git a/README.md b/README.md
index 0dccde7..7920e7e 100644
--- a/README.md
+++ b/README.md
@@ -1,934 +1,212 @@
-# SENTIGON
+
-**Agentic Physical Security Intelligence Platform**
+# π‘οΈ SENTIGON
-Sentinel AI is a production-grade, full-stack security operations platform that unifies real-time video analytics, multi-modal threat detection, autonomous AI agents, and forensic investigation into a single Security Operations Center (SOC). The system processes live camera feeds through a layered perception-reasoning-action agent architecture, correlates events across cameras, sensors, and access control systems, and provides operators with an intelligent command interface for physical security management.
+### The Agentic Security Operations Center that runs **100% on your own hardware**
----
-
-## Table of Contents
-
-1. [System Architecture](#system-architecture)
-2. [Architecture Diagram](#architecture-diagram)
-3. [Technology Stack](#technology-stack)
-4. [Project Structure](#project-structure)
-5. [Agent System](#agent-system)
-6. [Backend Services](#backend-services)
-7. [API Surface](#api-surface)
-8. [Frontend Application](#frontend-application)
-9. [Database Schema](#database-schema)
-10. [Threat Detection Engine](#threat-detection-engine)
-11. [Integration Layer](#integration-layer)
-12. [Quick Start](#quick-start)
-13. [Configuration](#configuration)
-14. [RBAC and Authentication](#rbac-and-authentication)
-15. [Deployment](#deployment)
-
----
-
-## System Architecture
-
-
-

-
-
-Sentinel AI follows a layered architecture with four principal tiers:
-
-**Data Ingestion Layer**: Camera feeds (RTSP, USB, ONVIF), IoT sensors (MQTT, Modbus), PACS card readers, alarm panels, and environmental monitors continuously stream data into the platform.
-
-**Perception Layer**: YOLOv8 performs real-time object detection with ByteTrack multi-object tracking. CLIP generates visual embeddings for semantic search. Audio classifiers detect gunshots, glass breaking, and screams. Each feed is processed at up to 15 FPS with 30-frame ring buffers per camera.
-
-**Reasoning Layer**: Google Gemini provides scene understanding, forensic analysis, and natural language threat assessment. A threat engine with 165+ hybrid signatures (YOLO + Gemini keyword matching) classifies detections. Baseline learning services establish per-zone, per-time-slot normality profiles. Causal reasoning and counterfactual engines analyze incident chains.
-
-**Action Layer**: Autonomous response services trigger alerts, lockdowns, dispatch assignments, and multi-channel notifications. A human-in-the-loop (HITL) approval system gates high-impact actions. All actions are audited with full chain-of-custody logging.
-
-A central supervisor agent (Sentinel Cortex) orchestrates 16+ specialized agents across these tiers, coordinating perception, analysis, and response through Redis Pub/Sub channels.
-
----
-
-

-
-

-
-

-
-

-
-

-
-

-
-

-
-## Architecture Diagram
-
-```
-+-----------------------------------------------------------------------------------+
-| SENTINEL AI PLATFORM |
-+-----------------------------------------------------------------------------------+
-| |
-| +---------------------------+ +---------------------------+ |
-| | DATA SOURCES | | EXTERNAL SYSTEMS | |
-| | | | | |
-| | RTSP/USB/ONVIF Cameras | | PACS (Card Readers) | |
-| | MQTT/Modbus Sensors | | Alarm Panels | |
-| | Audio Microphones | | SIEM (Splunk/ELK) | |
-| | Environmental Monitors | | Building Mgmt Systems | |
-| | Body Cameras | | Intercom / PA Systems | |
-| +------------+--------------+ +------------+--------------+ |
-| | | |
-| v v |
-| +------------------------------------------------------------------------+ |
-| | FASTAPI BACKEND (async) | |
-| | | |
-| | +-------------------+ +-------------------+ +--------------------+ | |
-| | | VIDEO CAPTURE | | YOLO DETECTOR | | GEMINI PROVIDERS | | |
-| | | Ring buffers (30f)| | YOLOv8n + ByteTrack| | Flash (realtime) | | |
-| | | RTSP reconnection | | 80+ object classes | | Pro (forensics) | | |
-| | | Up to 16 cameras | | Dwell/trajectory | | 10 req/min limit | | |
-| | +-------------------+ +-------------------+ +--------------------+ | |
-| | | |
-| | +-------------------+ +-------------------+ +--------------------+ | |
-| | | CLIP PIPELINE | | THREAT ENGINE | | ALERT MANAGER | | |
-| | | ViT-B-32 embeddings| | 165+ signatures | | Dedup (30s window) | | |
-| | | Semantic search | | YOLO+Gemini hybrid| | Auto-escalation | | |
-| | | Frame similarity | | Auto-learning | | Status transitions | | |
-| | +-------------------+ +-------------------+ +--------------------+ | |
-| | | |
-| | +------------------------------------------------------------------+ | |
-| | | AGENT ORCHESTRATION LAYER | | |
-| | | | | |
-| | | SUPERVISOR PERCEPTION REASONING | | |
-| | | +--------------+ +--------------+ +--------------+ | | |
-| | | |Sentinel | |SentinelEye | |ThreatAnalyst | | | |
-| | | |Cortex | |PatrolAgent | |Investigator | | | |
-| | | | | |CrowdMonitor | |Correlator | | | |
-| | | |Orchestrates | |LPRAgent | |ReIDAgent | | | |
-| | | |all agents, | |AudioAgent | |GhostTracer | | | |
-| | | |resolves | |TamperAgent | |CompanionAgent| | | |
-| | | |conflicts, | |PPEAgent | +--------------+ | | |
-| | | |shift handoff | |EnvAgent | | | |
-| | | +--------------+ |MicroBehavior | ACTION | | |
-| | | |AnomalyDetect | +--------------+ | | |
-| | | +--------------+ |ResponseAction| | | |
-| | | |ReportAgent | | | |
-| | | Communication: Redis Pub/Sub |DispatchAgent | | | |
-| | | Memory: Short-term (Redis) + |ComplianceAgt | | | |
-| | | Long-term (PostgreSQL) +--------------+ | | |
-| | +------------------------------------------------------------------+ | |
-| | | |
-| | +-------------------+ +-------------------+ +--------------------+ | |
-| | | REST API (80+) | | WEBSOCKET SERVER | | CELERY WORKERS | | |
-| | | Auth, Cameras, | | Multiplexed: | | Escalation checks | | |
-| | | Zones, Events, | | - Live frames | | Threat feed poll | | |
-| | | Alerts, Cases, | | - Alerts | | Baseline learning | | |
-| | | Forensics, Agents | | - Agent activity | | Embedding cleanup | | |
-| | | Search, Analytics | | - Metrics | | Video retention | | |
-| | | Threat, PACS, LPR | | - Notifications | | Report generation | | |
-| | +-------------------+ +-------------------+ +--------------------+ | |
-| +------------------------------------------------------------------------+ |
-| | | | |
-| v v v |
-| +-------------------+ +-------------------+ +--------------------+ |
-| | POSTGRESQL 16 | | QDRANT | | REDIS 7 | |
-| | Users, Cameras, | | sentinel_events | | Session cache | |
-| | Zones, Events, | | vehicle_sightings | | Agent memory | |
-| | Alerts, Cases, | | entity_appearances| | Pub/Sub channels | |
-| | Incidents, Threat | | audio_events | | Rate limiting | |
-| | Signatures, Audit | | frame_embeddings | | Celery broker | |
-| | Logs, Recordings | | (384-dim vectors) | | | |
-| +-------------------+ +-------------------+ +--------------------+ |
-| |
-| +--------------------------------------------------------+ |
-| | NEXT.JS 16 FRONTEND | |
-| | | |
-| | +---------------+ +---------------+ +------------+ | |
-| | | SOC DASHBOARD | | FORENSICS | | AGENT UI | | |
-| | | Video Wall | | Investigation | | Fleet Mgmt | | |
-| | | Alert Feed | | Case Builder | | Chat | | |
-| | | Metrics Bar | | Timeline View | | Cortex | | |
-| | | Threat Level | | What-If Panel | | Activity | | |
-| | +---------------+ +---------------+ +------------+ | |
-| | | |
-| | +---------------+ +---------------+ +------------+ | |
-| | | DETECTION | | OPERATIONS | | ANALYTICS | | |
-| | | LPR / ReID | | Incidents | | Trends | | |
-| | | Audio Intel | | Dispatch | | Heatmaps | | |
-| | | Behavioral AI | | Patrol Routes | | Facility | | |
-| | | Entity Track | | SOPs / BOLO | | Map (GIS) | | |
-| | +---------------+ +---------------+ +------------+ | |
-| | | |
-| | 65 pages | 57+ components | 8 hooks | WebSocket | |
-| +--------------------------------------------------------+ |
-+-----------------------------------------------------------------------------------+
-```
-
-### Data Flow
-
-```
-Camera Feed --> Video Capture (ring buffer) --> YOLO Detection (per frame)
- | |
- | v
- | Threat Engine (165+ signatures)
- | |
- +--> CLIP Embedding --> Qdrant Alert Manager (dedup + escalate)
- | |
- +--> Gemini Analysis (rate-limited) WebSocket --> Frontend
- | |
- +--> Auto Recorder (5-min chunks) Agent Orchestration (Cortex)
- |
- Redis Pub/Sub --> All Agents
-```
-
----
-
-## Technology Stack
-
-### Backend
-
-| Component | Technology | Purpose |
-|-----------|-----------|---------|
-| Web Framework | FastAPI (async) | REST API, WebSocket, middleware |
-| Language | Python 3.12 | Async/await throughout |
-| ORM | SQLAlchemy 2.0 (asyncpg) | Database models, async queries |
-| Database | PostgreSQL 16 | Primary data store |
-| Vector Database | Qdrant | Semantic search, frame similarity |
-| Cache / Pub-Sub | Redis 7 | Sessions, agent comms, rate limiting |
-| Task Queue | Celery | Background jobs, scheduled tasks |
-| Object Detection | YOLOv8n + ByteTrack | Real-time detection and tracking |
-| Visual Embeddings | CLIP ViT-B-32 | Frame similarity, semantic video search |
-| Text Embeddings | all-MiniLM-L6-v2 | Event embedding for search (384-dim) |
-| LLM (Primary) | Google Gemini (Flash/Pro) | Scene analysis, forensics, chat |
-| LLM (Fallback) | Ollama (gemma3:4b) | Local inference when Gemini unavailable |
-| Computer Vision | OpenCV | Frame capture, image processing |
-| Migrations | Alembic | Schema versioning |
-| Auth | JWT (HS256) + bcrypt | Token auth with role-based access |
-| Observability | Prometheus, OpenTelemetry | Metrics, distributed tracing |
-| Logging | structlog | Structured JSON logging |
-
-### Frontend
-
-| Component | Technology | Purpose |
-|-----------|-----------|---------|
-| Framework | Next.js 16.1.6 (Turbopack) | App Router, SSR |
-| Language | TypeScript + React 19 | Type-safe components |
-| Styling | Tailwind CSS v4 | Utility-first dark theme |
-| UI Components | Radix UI (10 packages) | Accessible dialog, tabs, toast, etc. |
-| Icons | Lucide React | 560+ security-relevant icons |
-| Charts | Recharts | Bar, area, radial charts |
-| Maps | Leaflet + react-leaflet | Facility maps, geofences, tracking |
-| State | React hooks + WebSocket | Real-time reactive updates |
-
-### Infrastructure
-
-| Component | Technology | Purpose |
-|-----------|-----------|---------|
-| Containers | Docker Compose | PostgreSQL, Qdrant, Redis |
-| Reverse Proxy | Nginx | Production routing |
-| GPU Support | CUDA (optional) | FP16 inference on RTX cards |
-
----
-
-## Project Structure
-
-```
-sentinel-ai/
-|
-+-- backend/
-| +-- main.py # App entrypoint, lifespan, 80+ router mounts
-| +-- config.py # Pydantic settings (DB, AI, video, auth)
-| +-- database.py # Async SQLAlchemy engine + session factory
-| +-- models/
-| | +-- models.py # Core: User, Camera, Zone, Event, Alert, Case, etc.
-| | +-- phase2b_models.py # Incident, Visitor, VideoWall, SIEM, VIP, Lockdown
-| | +-- phase3_models.py # Baselines, ContextRule, Intent, AlarmCorrelation
-| | +-- agent_state.py # AgentState, AgentMemory persistence
-| | +-- agent_audit.py # AgentAuditLog for all agent actions
-| +-- schemas/ # Pydantic request/response models
-| +-- api/ # 80+ REST endpoint modules
-| | +-- auth.py # Login, register, token refresh, RBAC
-| | +-- cameras.py # Camera CRUD, health, ONVIF
-| | +-- zones.py # Zone polygons, occupancy, breach alerts
-| | +-- events.py # Event recording, search, filtering
-| | +-- alerts.py # Alert lifecycle, escalation, assignment
-| | +-- cases.py # Case management, evidence chain
-| | +-- forensics.py # Deep analysis, timeline, correlation
-| | +-- agents.py # Agent fleet status, lifecycle, audit
-| | +-- copilot.py # Natural language SOC assistant
-| | +-- ws.py # WebSocket multiplexing
-| | +-- lpr.py # License plate recognition
-| | +-- audio.py # Audio event detection
-| | +-- weapon_detection.py # Weapon/tool detection
-| | +-- pacs.py # Physical access control
-| | +-- incidents.py # Incident lifecycle + SLA
-| | +-- privacy.py # PII masking, GDPR/CCPA
-| | +-- ... (60+ more)
-| +-- services/ # 80+ service modules
-| | +-- video_capture.py # Threaded multi-camera capture
-| | +-- yolo_detector.py # YOLOv8 + ByteTrack detection
-| | +-- gemini_provider.py # Gemini API client (rate-limited)
-| | +-- gemini_analyzer.py # Real-time scene analysis
-| | +-- gemini_forensics.py # Deep forensic frame analysis
-| | +-- clip_pipeline.py # Background CLIP embedding pipeline
-| | +-- clip_embedder.py # CLIP model (ViT-B-32, 512-dim)
-| | +-- vector_store.py # Qdrant client (5 collections)
-| | +-- threat_engine.py # 165+ hybrid threat signatures
-| | +-- alert_manager.py # Dedup, escalation, correlation
-| | +-- baseline_learning.py # Per-zone normality baselines
-| | +-- causal_reasoning.py # Incident chain analysis
-| | +-- counterfactual.py # What-if scenario engine
-| | +-- intent_classifier.py # Behavioral intent analysis
-| | +-- autonomous_response.py # Auto threat response
-| | +-- ... (60+ more)
-| +-- agents/ # 16+ autonomous AI agents
-| | +-- base_agent.py # Base with memory, tools, circuit breaker
-| | +-- sentinel_cortex.py # Supervisor / orchestrator
-| | +-- sentinel_eye.py # Continuous camera monitoring
-| | +-- threat_analyst.py # Threat scoring and classification
-| | +-- investigator.py # Multi-step case investigation
-| | +-- correlator.py # Cross-camera event correlation
-| | +-- lpr_agent.py # License plate recognition
-| | +-- audio_agent.py # Audio event detection
-| | +-- crowd_monitor.py # Crowd density and flow
-| | +-- patrol_agent.py # Guard patrol optimization
-| | +-- ghost_tracer.py # Predictive entity tracking
-| | +-- ... (5+ more)
-| +-- middleware/
-| | +-- rate_limit.py # Token bucket (100 req/min default)
-| | +-- prometheus_metrics.py # Prometheus export
-| | +-- circuit_breaker.py # External API circuit breaker
-| | +-- csrf_protection.py # CSRF token validation
-| | +-- security_headers.py # HSTS, CSP, X-Frame-Options
-| | +-- tracing.py # OpenTelemetry distributed tracing
-| +-- tasks/ # Celery periodic tasks
-| +-- templates/ # Email/report templates
-| +-- reports/ # Generated report output
-|
-+-- frontend/
-| +-- src/
-| | +-- app/ # 65 page routes (Next.js App Router)
-| | | +-- layout.tsx # Root layout, sidebar, auth, copilot
-| | | +-- login/ # Authentication page
-| | | +-- command-center/ # SOC command center
-| | | +-- video-wall/ # Multi-camera video wall
-| | | +-- alerts/ # Alert management
-| | | +-- incidents/ # Incident lifecycle
-| | | +-- forensics/ # Forensic analysis
-| | | +-- cases/ # Case management
-| | | +-- search/ # Semantic + forensic search
-| | | +-- agents/ # Agent fleet management
-| | | +-- copilot/ # SOC Copilot chat
-| | | +-- lpr/ # License plate reader
-| | | +-- audio/ # Audio intelligence
-| | | +-- behavioral/ # Behavioral AI
-| | | +-- entity-tracking/ # Entity lifecycle
-| | | +-- reid/ # Person re-identification
-| | | +-- threat-config/ # Threat signature config
-| | | +-- pacs/ # Access control
-| | | +-- patrol/ # Patrol command
-| | | +-- analytics/ # Dashboards and trends
-| | | +-- zones/ # Zone management
-| | | +-- site-map/ # GIS facility map
-| | | +-- floor-plans/ # Floor plan engine
-| | | +-- compliance/ # Compliance dashboard
-| | | +-- privacy/ # Privacy and GDPR
-| | | +-- settings/ # System settings
-| | | +-- admin/ # Multi-tenant administration
-| | | +-- ... (40+ more)
-| | +-- components/
-| | | +-- soc/ # SOC dashboard (9 components)
-| | | +-- forensics/ # Investigation tools (12 components)
-| | | +-- agents/ # Agent UI (6 components)
-| | | +-- common/ # Shared utilities (22 components)
-| | | +-- copilot/ # Copilot chat widget
-| | | +-- map/ # Leaflet facility map
-| | | +-- lpr/ # LPR visualization (4 components)
-| | | +-- audio/ # Audio heatmap timeline
-| | | +-- threat-intel/ # Threat intel panels (3 components)
-| | | +-- ui/ # Base UI (Badge, Card)
-| | +-- hooks/
-| | | +-- useWebSocket.ts # Multi-channel WebSocket with reconnect
-| | | +-- useLiveFeed.ts # Frame + analysis batching
-| | | +-- useAlerts.ts # Alert CRUD + real-time
-| | | +-- useThreatResponse.ts # Threat response tracking
-| | | +-- useSmartPolling.ts # Visibility-aware polling
-| | | +-- useApiError.ts # Error handling wrapper
-| | | +-- useDebounce.ts # Debounced state
-| | | +-- useKeyboardShortcuts.ts # Keyboard bindings
-| | +-- lib/
-| | +-- types.ts # 65+ TypeScript interfaces
-| | +-- utils.ts # apiFetch, formatters, API config
-| | +-- export.ts # CSV, PDF, JSON export utilities
-| +-- package.json
-| +-- tailwind config (v4, CSS-based)
-|
-+-- docker-compose.yml # PostgreSQL 16, Qdrant, Redis
-+-- docker-compose.prod.yml # Production with Nginx
-+-- nginx.conf # Reverse proxy config
-+-- requirements.txt # Python dependencies
-+-- alembic.ini # Database migration config
-+-- yolov8n.pt # YOLO model weights
-+-- yolov8n-pose.pt # Pose estimation model weights
-```
-
----
-
-## Agent System
-
-Sentinel AI runs a hierarchical multi-agent system where 16+ specialized agents operate autonomously, coordinated by a central supervisor.
-
-### Agent Tiers
-
-**Supervisor Tier**
-
-| Agent | Role |
-|-------|------|
-| Sentinel Cortex | Central intelligence coordinator. Orchestrates all agents, resolves conflicting assessments, manages shift handoffs, produces situation reports. |
-
-**Perception Tier (Always-On)**
-
-| Agent | Role |
-|-------|------|
-| SentinelEye | Continuous round-robin camera monitoring with YOLO + Gemini scene analysis |
-| PatrolAgent | Guard patrol tracking and route optimization |
-| AnomalyDetector | Statistical anomaly detection against learned baselines |
-| CrowdMonitor | Crowd density estimation, flow analysis, stampede risk |
-| LPRAgent | License plate recognition and database lookup |
-| AudioAgent | Audio event classification (gunshots, screams, glass) |
-| EnvironmentalAgent | Temperature, humidity, air quality monitoring |
-| TamperAgent | Camera tampering detection (blur, tilt, obstruction) |
-| PPEAgent | Personal protective equipment compliance |
-| MicroBehaviorAgent | Micro-expression and gait anomaly analysis |
-
-**Reasoning Tier**
-
-| Agent | Role |
-|-------|------|
-| ThreatAnalyst | Threat severity scoring and kill-chain classification |
-| Investigator | Multi-step autonomous incident investigation |
-| Correlator | Cross-camera, cross-modal event correlation |
-| ReIDAgent | Person re-identification across cameras |
-| GhostTracer | Predictive tracking of disappeared entities |
-| CompanionAgent | Companion and group relationship discovery |
-
-**Action Tier**
-
-| Agent | Role |
-|-------|------|
-| ResponseAction | Autonomous threat response (alerts, lockdowns, access revocation) |
-| ReportAgent | Automated report generation and formatting |
-| DispatchAgent | Operator dispatch, resource assignment |
-| ComplianceAgent | Compliance-aware response gating |
-
-### Agent Infrastructure
-
-Each agent inherits from a base class that provides:
-
-- **Async function-calling loop** with Gemini or Ollama
-- **Short-term memory** stored in Redis for fast access
-- **Long-term memory** persisted to PostgreSQL
-- **Circuit breaker** with exponential backoff for failure recovery
-- **Token budgeting** per cycle (50,000 tokens default)
-- **Tool registry** with access to 50+ specialized tools
-- **Full audit logging** of every decision and action
-- **Adaptive idle slowdown** to conserve compute when nothing is happening
-
-### Agent Communication
-
-Agents communicate through Redis Pub/Sub on dedicated channels:
-
-```
-sentinel:agents:cortex # Supervisor directives
-sentinel:agents:perceptions # Perception observations
-sentinel:agents:threats # Threat assessments
-sentinel:agents:actions # Action events
-sentinel:agents:investigation # Investigation steps
-sentinel:agents:anomalies # Anomaly reports
-sentinel:agents:correlation # Event correlations
-sentinel:agents:predictions # Predictive insights
-sentinel:agents:heartbeat # Health monitoring
-```
-
----
+**Turn ordinary camera feeds into an autonomous, reasoning command center β with local AI, zero cloud, and no data ever leaving your network.**
-## Backend Services
-
-The backend contains 80+ service modules organized by domain.
-
-### Core Processing
-
-| Service | Description |
-|---------|------------|
-| video_capture.py | Threaded multi-camera capture with 30-frame ring buffers, RTSP fallback, auto-reconnection |
-| yolo_detector.py | YOLOv8n inference with ByteTrack tracking, dwell time, trajectory analysis |
-| clip_pipeline.py | Background CLIP embedding pipeline (ViT-B-32, 512-dim frame vectors) |
-| alert_manager.py | Alert deduplication (30s window), severity-based escalation, cross-alert correlation |
-| threat_engine.py | 165+ threat signatures with hybrid YOLO + Gemini detection methods |
-| auto_recorder.py | Continuous and event-triggered video recording (5-min chunks, 72h retention) |
-
-### AI and LLM
-
-| Service | Description |
-|---------|------------|
-| gemini_provider.py | Gemini API client with built-in rate limiting (10 req/min) |
-| gemini_analyzer.py | Real-time scene analysis via Gemini Flash |
-| gemini_forensics.py | Deep forensic frame analysis via Gemini Pro |
-| ollama_provider.py | Local Ollama model provider as Gemini fallback |
-| ai_text_service.py | Text generation and summarization |
-| causal_reasoning.py | Causal inference for incident chain analysis |
-| counterfactual_engine.py | "What-if" scenario simulation |
-| intent_classifier.py | Behavioral intent classification (authorized/evasive/forced_entry) |
-| explanation_builder.py | Explainable AI reasoning chain generation |
-
-### Vector Search
-
-| Service | Description |
-|---------|------------|
-| vector_store.py | Qdrant client managing 5 collections: sentinel_events, vehicle_sightings, entity_appearances, audio_events, frame_embeddings |
-| clip_embedder.py | CLIP model management for visual similarity search |
-
-### Operational
-
-| Service | Description |
-|---------|------------|
-| incident_lifecycle_service.py | Incident state machine with SLA timers |
-| autonomous_response.py | Automated threat response actions |
-| pending_action_service.py | Human-in-the-loop approval queue |
-| operation_mode.py | Adaptive operation modes (normal/alert/lockdown) |
-| emergency_codes.py | No-auth emergency code execution |
-| shift_briefing.py | Shift handoff report generation |
-| sop_engine.py | Standard Operating Procedure automation |
-| patrol_optimizer.py | Guard route optimization |
-
-### Integration
-
-| Service | Description |
-|---------|------------|
-| pacs_service.py | Physical Access Control System (card readers, turnstiles) |
-| alarm_panel_service.py | Alarm panel integration |
-| alarm_correlation_engine.py | Cross-modal alarm fusion (PACS + video + sensors) |
-| siem_service.py | SIEM integration (Splunk, ELK) |
-| mqtt_service.py | MQTT sensor data ingestion |
-| onvif_service.py | ONVIF camera protocol support |
-| bms_service.py | Building Management System integration |
-| elevator_service.py | Elevator system integration |
-| bodycam_service.py | Body camera integration |
-| gis_service.py | Geographic Information System mapping |
-
-### Privacy and Compliance
-
-| Service | Description |
-|---------|------------|
-| privacy_engine_service.py | PII masking, privacy zones, data anonymization |
-| compliance_auditor.py | Regulatory audit trail (SOC2, HIPAA, PCI-DSS, GDPR/CCPA) |
-| compliance_dashboard_service.py | Compliance metrics and dashboards |
+
----
-
-## API Surface
-
-The backend exposes 80+ REST API endpoint groups and a multiplexed WebSocket server.
-
-### Endpoint Categories
-
-| Category | Prefix | Endpoints | Description |
-|----------|--------|-----------|------------|
-| Authentication | /api/auth | 4 | Login, register, token refresh, user management |
-| Cameras | /api/cameras | 5 | CRUD, health checks, ONVIF discovery |
-| Zones | /api/zones | 5 | Polygon CRUD, occupancy tracking, breach alerts |
-| Events | /api/events | 8 | Recording, search, filtering, correlation, export |
-| Alerts | /api/alerts | 10 | Lifecycle, escalation, assignment, acknowledge, resolve |
-| Cases | /api/cases | 8 | Case CRUD, evidence attachment, AI insights |
-| Forensics | /api/forensics | 6 | Deep analysis, timeline, context windows |
-| Video | /api/video-* | 12 | Playback, bookmarks, archive, summary, wall layouts |
-| Search | /api/search | 3 | Full-text, semantic, visual similarity |
-| Agents | /api/agents | 8 | Fleet status, lifecycle, audit, conversation |
-| Copilot | /api/copilot | 3 | Natural language SOC assistant |
-| LPR | /api/lpr | 4 | Plate detection, database, cross-site correlation |
-| Audio | /api/audio | 3 | Audio event detection and classification |
-| Threat | /api/threat-* | 12 | Signatures, config, intel feeds, response |
-| PACS | /api/pacs | 4 | Access control events, door status |
-| Incidents | /api/incidents | 6 | Incident lifecycle with SLA tracking |
-| Behavioral | /api/behavioral-* | 4 | Behavioral analytics, baselines, intent |
-| Privacy | /api/privacy | 5 | PII masking, privacy zones, GDPR |
-| Compliance | /api/compliance | 4 | Audit trails, regulatory dashboards |
-| Integration | /api/alarm-panels, iot-sensors, siem, etc. | 18 | External system connectors |
-| Emergency | /api/emergency | 3 | No-auth lockdown, evacuation codes |
-
-### WebSocket Channels
-
-The `/ws` endpoint multiplexes the following real-time channels:
-
-| Channel | Data | Rate |
-|---------|------|------|
-| frame / frames | Live MJPEG frames (base64) | Up to 15 FPS |
-| alert / alerts | New and updated alerts | Event-driven |
-| metric / metrics | System KPIs | 1/s |
-| notifications | User notifications | Event-driven |
-| agent_activity | Agent execution logs | Event-driven |
-| pending_actions | HITL approval queue | Event-driven |
-| analysis | Gemini analysis results | Rate-limited |
-| crowd_sentiment | Crowd density/mood data | 1/s |
-| companion_discovery | Entity relationship detections | Event-driven |
-| ghost_trace | Predictive entity paths | Event-driven |
-| threat_response | Active response actions | Event-driven |
-
----
-
-## Frontend Application
-
-The frontend is a Next.js 16 application with 65 page routes organized into 9 navigation groups.
-
-### Page Groups
-
-**Operations** (7 pages): Dashboard, Command Center, Video Wall, Cameras, System Status, SOC Workspace, SOC Copilot
-
-**Alerts and Response** (8 pages): Emergency Response, Alerts, Incidents, Threat Response, Alarm Analysis, Pending Actions (HITL), Mass Notifications, Evacuation
-
-**Investigation** (7 pages): Search (Forensic/Visual/Semantic), Forensics, Cases, Evidence, Link Analysis, Video Summary, Video Archive
-
-**Detection and AI** (8 pages): Agent Fleet Management, Auto Investigations, Behavioral AI, Context Intelligence, Entity Tracking, Person Re-ID, Plate Reader, Audio Intelligence
-
-**Threat Management** (6 pages): Threat Config, Signatures, Threat Intel Feeds, Insider Threat, Tamper Detection, BOLO and Logbook
-
-**Access and Patrol** (7 pages): Access Control (PACS), Visitors, VIP Protection, Patrol Command, Dispatch, Crowd Protocols, SOP Manager
-
-**Analytics and Maps** (7 pages): Analytics Dashboards, Zones, Site Map (GIS), Floor Plans, Global Overwatch, Environmental Safety, SLA Dashboard
-
-**Compliance and Privacy** (2 pages): Compliance Dashboard, Privacy and GDPR Center
-
-**System** (5 pages): Integrations, Webhooks, Settings, Customer Portal, Administration
+[](#-why-sentigon)
+[](#-why-sentigon)
+[](#-tech-stack)
+[](#-tech-stack)
+[](#-tech-stack)
-### Key Component Libraries
+[](#-contributing)
+
+
+
-**SOC Components** (9 files, 2,000+ lines): Video wall with configurable grid layouts, live alert feed with status colors, camera grid, crowd sentiment badges, live frame/analysis panel, metrics bar, threat level indicator, zone overlays.
+
-**Forensics Components** (12 files, 130,000+ lines): Agent decision timeline, case builder, detection overlay, incident list, incident replay player, investigation panel, multi-camera replay grid, reconstruction panel, semantic search bar, similar frames panel, timeline view, what-if scenario panel.
+**[ Why ](#-why-sentigon)** Β· **[ Features ](#-what-it-actually-does)** Β· **[ The Console ](#%EF%B8%8F-the-command-center)** Β· **[ Quick Start ](#-quick-start-5-minutes)** Β· **[ Architecture ](#%EF%B8%8F-architecture)** Β· **[ Tech ](#-tech-stack)**
-**Agent Components** (6 files, 75,000+ lines): Agent activity panel, chat interface with Sentinel Cortex, control panel, conversation history, status grid by tier, Cortex overview dashboard.
-
-**Common Components** (22 files): Annotation canvas, bounding box overlay, confidence slider, connection status, data state (loading/empty/error), error boundary, file upload, metric sparklines, neural glass overlay, pose overlay, skeleton loaders, SLA countdown, system health gauge, timeline view, toast notifications.
-
-### Design System
-
-The frontend uses a dark theme optimized for SOC environments:
-
-- Background: #030712 (near-black)
-- Card surfaces: #111827
-- Accent color: #06b6d4 (cyan)
-- Severity mapping: red (critical), orange (high), yellow (medium), blue (low)
-- Custom animations: pulse-alert, threat-pulse, slide-in, fade-in, rec-blink
-- Glow effects for severity indicators (.glow-red, .glow-cyan, etc.)
+
---
-## Database Schema
-
-### Core Models
-
-| Model | Key Fields | Purpose |
-|-------|-----------|---------|
-| User | email, hashed_password, role, is_active | Authentication, RBAC |
-| Camera | name, source, location, status, fps, zone_id, config (JSONB) | Camera registry |
-| Zone | name, zone_type, polygon, max_occupancy, alert_on_breach | Geographic zones |
-| Event | camera_id, zone_id, event_type, severity, confidence, detections (JSONB), gemini_analysis | Detection events |
-| Alert | event_id, severity, status, threat_type, assigned_to, correlated_alert_ids | Alert lifecycle |
-| Case | title, status, priority, assigned_to, ai_insights | Investigation cases |
-| CaseEvidence | case_id, evidence_type, reference_id, file_url | Evidence chain |
-| Recording | camera_id, file_path, duration_seconds, event_id | Video recordings |
-| VideoBookmark | recording_id, timestamp_offset, label, ai_analysis | Bookmarked moments |
-| ThreatSignature | name, category, severity, detection_method, yolo_classes, gemini_prompt | Threat rules |
-| AuditLog | user_id, action, resource_type, resource_id, ip_address | Audit trail |
-| ActivityNotification | name, rule_nl, severity, zones, schedule | NL alert rules |
-
-### Extended Models
-
-| Model | Purpose |
-|-------|---------|
-| Incident | Full incident lifecycle with SLA timers (acknowledge/respond/resolve) |
-| IncidentStatusLog | Status change audit trail |
-| Visitor | Pre-registration, check-in/out, overstay detection |
-| NotificationBroadcast | Multi-channel emergency broadcasts (email, SMS, push, PA, signage) |
-| VideoWallLayout | Multi-monitor grid configuration |
-| BehavioralEvent | Suspicious pattern detection (loitering, casing, tailgating) |
-| SIEMConnector | External SIEM integration configuration |
-| VIPProfile | VIP tracking with location alerts |
-| SecurityProcedure | SOP management with automation triggers |
-| LockdownSequence | Emergency lockdown automation |
-
-### Intelligence Models
-
-| Model | Purpose |
-|-------|---------|
-| ActivityBaseline | Learned per-camera/zone/time-slot normality profiles |
-| ContextRule | Zone-type-aware context scoring (restricted/kitchen/lobby/server_room) |
-| IntentClassification | Behavioral intent (authorized_access/reconnaissance/evasive/forced_entry) |
-| AlertFeedback | User feedback for ML model tuning |
-| AlarmCorrelation | Cross-modal alarm fusion (PACS + video + sensors) |
-| EntityAppearance | Re-identification descriptors for person tracking |
-| WeaponDetection | Weapon/tool detection state management |
-
-### Vector Collections (Qdrant)
-
-| Collection | Dimensions | Content |
-|------------|-----------|---------|
-| sentinel_events | 384 | Text embeddings of security events |
-| vehicle_sightings | 384 | Vehicle description embeddings |
-| entity_appearances | 384 | Person appearance embeddings |
-| audio_events | 384 | Audio event description embeddings |
-| frame_embeddings | 512 | CLIP visual frame embeddings |
+> **TL;DR** β Sentigon ingests live camera feeds and runs them through a fleet of **12 cooperating AI agents** (perception β reasoning β action β supervision) that *detect, verify, reason about, and respond to* physical-security threats in real time. Every model β vision, language, OCR, audio β runs **locally**. There is **no cloud, no API key, no telemetry**. Your footage stays yours.
----
+
----
+### If a private, local-first, agentic SOC is something the world should have β
-## Middleware Stack
+# β Star the repo
-| Middleware | Purpose |
-|-----------|---------|
-| Rate Limiting | Token bucket: 100 req/min general, 10 req/min login, 30 req/min AI endpoints |
-| Prometheus | HTTP latency histograms, business KPI counters |
-| Circuit Breaker | Protects external API calls (Gemini, SIEM, PACS) with exponential backoff |
-| CSRF Protection | Token validation on state-changing requests |
-| Security Headers | HSTS, Content-Security-Policy, X-Frame-Options, X-Content-Type-Options |
-| Distributed Tracing | OpenTelemetry trace propagation |
-| Audit Logging | All POST/PUT/PATCH/DELETE requests logged with user context |
+It genuinely helps, and it's the fastest way to follow where this goes.
----
-
----
-
-## Author
-
-**Sherin Joseph Roy**
-[https://sherinjosephroy.link](https://sherinjosephroy.link)
-
----
+
-## License
+**Built for operators who refuse to send their footage to someone else's cloud.**
-Proprietary. All rights reserved.
+
diff --git a/backend/agents/consolidated/detector.py b/backend/agents/consolidated/detector.py
index e5888c8..7387c0c 100644
--- a/backend/agents/consolidated/detector.py
+++ b/backend/agents/consolidated/detector.py
@@ -165,27 +165,52 @@ async def _pipeline_lpr(self, camera_id: str, cam: dict, objects: list[dict]) ->
if not vehicles:
return None
cam_name = cam.get("name", camera_id)
- result = await self.execute_tool_loop(
- prompt=(
- f"Analyze current frame from camera {camera_id} ({cam_name}). "
- f"{len(vehicles)} vehicle(s) detected. Use analyze_frame_with_gemini to "
- f"read all visible license plates. For each plate report: plate_text, "
- f"vehicle_type, vehicle_color, confidence. Then use store_observation to "
- f"record each reading with category='plate_read'."
- ),
- context_data={"camera_id": camera_id, "vehicle_count": len(vehicles), "task": "lpr"},
- )
- resp = result.get("response", "")
+
+ # Real local ALPR (EasyOCR) β fast, deterministic plate reads instead of
+ # the slow/inconsistent vision-model path. Each vehicle box is OCR'd and
+ # checked against active vehicle BOLOs.
+ from backend.services.alpr_service import alpr_service
+ if not alpr_service.available():
+ return None
+ from backend.services.video_capture import capture_manager
+ stream = capture_manager.get_stream(camera_id)
+ latest = stream.get_latest_frame() if stream else None
+ if latest is None:
+ return None
+ _, frame = latest
+
+ reads: list[dict] = []
+ bolo_hits: list[dict] = []
+ for v in vehicles:
+ bbox = v.get("bbox")
+ if not bbox:
+ continue
+ r = await alpr_service.read_and_match(frame, bbox)
+ if r.get("plate"):
+ reads.append({"plate": r["plate"], "confidence": r.get("confidence"), "track_id": v.get("track_id")})
+ if r.get("matches"):
+ bolo_hits.append({"plate": r["plate"], "matches": r["matches"], "track_id": v.get("track_id")})
+ if not reads:
+ return None
+
await self.send_message(CH_PERCEPTIONS, {
"type": "plate_read", "camera_id": camera_id, "camera_name": cam_name,
- "vehicle_count": len(vehicles), "analysis": resp[:500],
+ "plates": reads, "bolo_hits": bolo_hits,
"timestamp": datetime.now(timezone.utc).isoformat(),
})
+ for hit in bolo_hits:
+ await self.send_message(CH_PERCEPTIONS, {
+ "type": "bolo_vehicle_match", "camera_id": camera_id, "camera_name": cam_name,
+ "plate": hit["plate"], "severity": "high",
+ "reason": "; ".join(m.get("reason", "BOLO vehicle") for m in hit["matches"]),
+ "timestamp": datetime.now(timezone.utc).isoformat(),
+ })
await self.log_action("lpr_scan", {
"camera_id": camera_id, "vehicles_detected": len(vehicles),
- "decision": f"LPR scan {cam_name}: {len(vehicles)} vehicle(s)",
+ "decision": f"ALPR {cam_name}: read {len(reads)} plate(s), {len(bolo_hits)} BOLO hit(s)",
})
- return {"vehicles_scanned": len(vehicles), "response": resp[:300]}
+ return {"vehicles_scanned": len(vehicles), "plates_read": len(reads),
+ "bolo_hits": len(bolo_hits), "plates": [r["plate"] for r in reads]}
# ββ Pipeline: PPE Compliance ββββββββββββββββββββββββββββββββββ
diff --git a/backend/agents/monitoring_agent.py b/backend/agents/monitoring_agent.py
index e3bbcd6..e9943e9 100644
--- a/backend/agents/monitoring_agent.py
+++ b/backend/agents/monitoring_agent.py
@@ -46,6 +46,8 @@
# AI vision is expensive; only invoke it every N frames per camera.
_AI_EVERY_N_FRAMES = 15
+# Refresh learned adaptive thresholds from the baseline service this often.
+_THRESHOLD_REFRESH_EVERY_N = 300
# Minimum seconds between full pipeline runs for a single camera.
_MIN_INTERVAL_SECONDS = 1.0
@@ -101,6 +103,19 @@ async def process_frame(
counter = self._frame_counters.get(camera_id, 0) + 1
self._frame_counters[camera_id] = counter
+ # Refresh learned adaptive thresholds + active BOLOs into sync caches (throttled).
+ if counter % _THRESHOLD_REFRESH_EVERY_N == 1:
+ try:
+ from backend.services.adaptive_thresholds import adaptive_thresholds
+ zid = zone_info.get("id") if zone_info else None
+ async with async_session() as _db:
+ await adaptive_thresholds.refresh(_db, camera_id, zid)
+ if getattr(settings, "BOLO_REALTIME_ENABLED", True):
+ from backend.services.bolo_matcher import bolo_matcher
+ await bolo_matcher.refresh(_db)
+ except Exception as exc: # noqa: BLE001
+ logger.debug("threshold/bolo refresh failed for %s: %s", camera_id, exc)
+
if settings.VISION_VERIFIED_DETECTION:
# Verified-vision path: structured scene intelligence + an adversarial
# verifier. Hallucination-resistant β only threats a skeptic confirms
@@ -145,6 +160,25 @@ async def process_frame(
except Exception as exc: # noqa: BLE001
logger.debug("pose behaviours failed for %s: %s", camera_id, exc)
+ # ββ 3a-quater. Real-time BOLO appearance matching ββββββββ
+ # Embed each new person track once and match against active person BOLOs.
+ if getattr(settings, "BOLO_REALTIME_ENABLED", True):
+ try:
+ from backend.services.bolo_matcher import bolo_matcher
+ if bolo_matcher.has_active():
+ threats.extend(bolo_matcher.scan_frame(frame, detections, camera_id))
+ except Exception as exc: # noqa: BLE001
+ logger.debug("BOLO scan failed for %s: %s", camera_id, exc)
+
+ # ββ 3a-quinquies. Threat-escalation chains βββββββββββββββ
+ # Detect escalating behaviour SEQUENCES on a single entity (e.g.
+ # loitering β running β fall) across the track-bearing threats above.
+ try:
+ from backend.services.escalation_tracker import escalation_tracker
+ threats.extend(escalation_tracker.observe(camera_id, threats, timestamp_epoch))
+ except Exception as exc: # noqa: BLE001
+ logger.debug("escalation tracking failed for %s: %s", camera_id, exc)
+
# ββ 3a-ter. SAM2 mask enrichment for FLAGGED objects ββββββ
# Only objects referenced by a threat get a pixel-precise SAM2 mask
# (occlusion-robust extent), attached to the threat + its detection so it
diff --git a/backend/api/bolo.py b/backend/api/bolo.py
index 66d2fd3..cb5ce81 100644
--- a/backend/api/bolo.py
+++ b/backend/api/bolo.py
@@ -200,6 +200,41 @@ async def deactivate_bolo(
raise HTTPException(status_code=500, detail="Failed to deactivate BOLO entry")
+class EnrollAppearanceRequest(BaseModel):
+ image_base64: str = Field(..., description="Reference image (base64 JPEG/PNG)")
+ bbox: Optional[List[float]] = Field(None, description="[x1,y1,x2,y2]; whole image if omitted")
+
+
+@router.post("/{bolo_id}/enroll-appearance")
+async def enroll_appearance(bolo_id: uuid.UUID, body: EnrollAppearanceRequest, _user=Depends(get_current_user)):
+ """Enroll a person BOLO with a CLIP appearance embedding from a reference
+ image so the real-time matcher can flag this person across cameras."""
+ import base64
+ import cv2
+ import numpy as np
+ from backend.services.appearance_embedder import appearance_embedding
+ from backend.services.bolo_service import bolo_service
+
+ try:
+ raw = base64.b64decode(body.image_base64.split(",", 1)[-1])
+ frame = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
+ except Exception:
+ raise HTTPException(status_code=400, detail="Invalid image_base64")
+ if frame is None:
+ raise HTTPException(status_code=400, detail="Could not decode image")
+
+ h, w = frame.shape[:2]
+ bbox = body.bbox or [0, 0, w, h]
+ emb = appearance_embedding(frame, bbox)
+ if not emb:
+ raise HTTPException(status_code=422, detail="Could not compute appearance embedding")
+
+ ok = await bolo_service.enroll_appearance(bolo_id, emb)
+ if not ok:
+ raise HTTPException(status_code=404, detail="BOLO not found")
+ return {"enrolled": True, "bolo_id": str(bolo_id), "embedding_dim": len(emb)}
+
+
@router.get("/{bolo_id}/sightings", response_model=List[dict])
async def get_bolo_sightings(
bolo_id: uuid.UUID,
diff --git a/backend/api/compliance_dashboard.py b/backend/api/compliance_dashboard.py
index 43ccb00..48c07af 100644
--- a/backend/api/compliance_dashboard.py
+++ b/backend/api/compliance_dashboard.py
@@ -53,6 +53,20 @@ async def compliance_history(
raise HTTPException(400, str(e))
+@router.get("/forecast")
+async def compliance_forecast(
+ framework: str = "gdpr",
+ target: float = Query(0.8, ge=0.0, le=1.0),
+ db: AsyncSession = Depends(get_db),
+):
+ """Project the compliance score from its recent trend and estimate when it
+ will breach the target β enabling proactive remediation."""
+ try:
+ return await compliance_dashboard_service.forecast_compliance(db, framework=framework, target=target)
+ except Exception as e:
+ raise HTTPException(400, str(e))
+
+
@router.get("/issues")
async def compliance_issues(
severity: str = None,
diff --git a/backend/api/forensics.py b/backend/api/forensics.py
index f4e7d39..c8b56d5 100644
--- a/backend/api/forensics.py
+++ b/backend/api/forensics.py
@@ -485,6 +485,40 @@ async def subject_search(
}
+class ImageSearchRequest(BaseModel):
+ image_base64: str = Field(..., description="Query image (base64 JPEG/PNG)")
+ bbox: Optional[List[float]] = Field(None, description="[x1,y1,x2,y2] crop; whole image if omitted")
+ camera_id: Optional[str] = None
+ max_results: int = Field(20, ge=1, le=100)
+
+
+@router.post("/search-by-image")
+async def search_by_image(
+ body: ImageSearchRequest,
+ _user=Depends(require_role(UserRole.ANALYST)),
+):
+ """Image-based "looks-like" forensic search: upload an image (or a crop) and
+ find the most visually similar people/objects seen across all cameras, using
+ CLIP embeddings against the object-crop vector store.
+ """
+ import base64
+ import cv2
+ import numpy as np
+ from backend.services.forensic_search_service import forensic_search_service
+
+ try:
+ raw = base64.b64decode(body.image_base64.split(",", 1)[-1])
+ frame = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
+ except Exception:
+ raise HTTPException(status_code=400, detail="Invalid image_base64")
+ if frame is None:
+ raise HTTPException(status_code=400, detail="Could not decode image")
+
+ return await forensic_search_service.search_objects_by_image(
+ frame, bbox=body.bbox, camera_id=body.camera_id, top_k=body.max_results,
+ )
+
+
@router.post("/movement-trail")
async def movement_trail(
body: MovementTrailRequest,
diff --git a/backend/api/intelligence.py b/backend/api/intelligence.py
index b7aa386..9af1fce 100644
--- a/backend/api/intelligence.py
+++ b/backend/api/intelligence.py
@@ -160,6 +160,50 @@ async def deep_analyze(body: SceneAnalyzeRequest):
}
+class AnalyzeAudioRequest(BaseModel):
+ audio_base64: str = Field(..., description="WAV audio (base64)")
+
+
+@router.post("/analyze-audio")
+async def analyze_audio(body: AnalyzeAudioRequest):
+ """Detect security-relevant sound events (gunshot/glass/scream/alarm) in a WAV
+ clip via the local audio DSP engine."""
+ import base64
+ from backend.services.audio_detection_service import audio_detection_service
+ try:
+ raw = base64.b64decode(body.audio_base64.split(",", 1)[-1])
+ except Exception:
+ raise HTTPException(status_code=400, detail="Invalid audio_base64")
+ events = audio_detection_service.analyze_bytes(raw)
+ return {"event_count": len(events), "events": events}
+
+
+class ReadPlateRequest(BaseModel):
+ image_base64: str = Field(..., description="Image containing a plate (base64)")
+ bbox: Optional[List[float]] = Field(None, description="Optional vehicle/plate crop [x1,y1,x2,y2]")
+
+
+@router.post("/read-plate")
+async def read_plate(body: ReadPlateRequest):
+ """Read a license plate via the local ALPR (EasyOCR) engine and check it
+ against active vehicle BOLOs."""
+ import base64
+ import cv2
+ import numpy as np
+ from backend.services.alpr_service import alpr_service
+
+ try:
+ raw = base64.b64decode(body.image_base64.split(",", 1)[-1])
+ frame = cv2.imdecode(np.frombuffer(raw, np.uint8), cv2.IMREAD_COLOR)
+ except Exception:
+ raise HTTPException(status_code=400, detail="Invalid image_base64")
+ if frame is None:
+ raise HTTPException(status_code=400, detail="Could not decode image")
+ if not alpr_service.available():
+ raise HTTPException(status_code=503, detail="ALPR engine unavailable")
+ return await alpr_service.read_and_match(frame, body.bbox)
+
+
class SegmentRequest(BaseModel):
camera_id: Optional[str] = None
image_base64: Optional[str] = None
diff --git a/backend/config.py b/backend/config.py
index da64bfa..b2acda3 100644
--- a/backend/config.py
+++ b/backend/config.py
@@ -125,6 +125,18 @@ class Settings(BaseSettings):
# this many objects per frame to bound cost.
SAM2_MAX_OBJECTS: int = 5
+ # ββ Real-time BOLO appearance matching βββββββββββββββββ
+ BOLO_REALTIME_ENABLED: bool = True
+ BOLO_MATCH_THRESHOLD: float = 0.82
+
+ # ββ ALPR (license-plate OCR via EasyOCR) βββββββββββββββ
+ ALPR_ENABLED: bool = True
+ ALPR_GPU: bool = False # CPU by default to avoid GPU contention with Ollama
+ ALPR_MIN_CONFIDENCE: float = 0.4
+
+ # ββ Audio event detection (DSP; pluggable deep model) ββ
+ AUDIO_DETECTION_ENABLED: bool = True
+
# ββ Qdrant ββββββββββββββββββββββββββββββββββββββββββββββββ
QDRANT_HOST: str = "localhost"
QDRANT_PORT: int = 6333
diff --git a/backend/services/adaptive_thresholds.py b/backend/services/adaptive_thresholds.py
new file mode 100644
index 0000000..243b999
--- /dev/null
+++ b/backend/services/adaptive_thresholds.py
@@ -0,0 +1,58 @@
+"""Adaptive thresholds cache β wires learned baselines into the hot loop.
+
+`baseline_learning_service.get_adaptive_thresholds` returns per-camera/zone
+thresholds (mean + 2*std of learned-normal), but it is async and hits the DB β
+too expensive to call per frame from the synchronous detection loop. This cache
+bridges the gap: an async `refresh()` (called on a throttle from the monitoring
+agent, which already holds a DB session) populates an in-memory table, and a
+fast sync `get()` lets hot-loop code (e.g. temporal_behaviour) read a learned
+threshold, transparently falling back to the caller's hard-coded default when no
+fresh baseline exists.
+"""
+from __future__ import annotations
+
+import logging
+import time
+import uuid
+from typing import Any, Dict, Optional
+
+logger = logging.getLogger(__name__)
+
+_TTL_SECONDS = 900.0 # a cached threshold older than this is treated as absent
+
+
+class AdaptiveThresholds:
+ def __init__(self) -> None:
+ # camera_id(str) -> {metric: value, "_t": fetched_at}
+ self._cache: Dict[str, Dict[str, Any]] = {}
+
+ def get(self, camera_id: str, key: str, default: float) -> float:
+ """Return a fresh learned threshold for the camera, else `default`."""
+ entry = self._cache.get(str(camera_id))
+ if not entry:
+ return default
+ if time.time() - entry.get("_t", 0.0) > _TTL_SECONDS:
+ return default
+ val = entry.get(key)
+ return float(val) if isinstance(val, (int, float)) else default
+
+ def source(self, camera_id: str) -> str:
+ entry = self._cache.get(str(camera_id))
+ if not entry or time.time() - entry.get("_t", 0.0) > _TTL_SECONDS:
+ return "default"
+ return entry.get("source", "default")
+
+ async def refresh(self, db, camera_id: str, zone_id: Optional[str] = None) -> None:
+ """Pull the latest learned thresholds for a camera into the cache."""
+ try:
+ from backend.services.baseline_learning_service import baseline_learning_service
+ cam_uuid = uuid.UUID(str(camera_id))
+ zone_uuid = uuid.UUID(str(zone_id)) if zone_id else None
+ thr = await baseline_learning_service.get_adaptive_thresholds(db, cam_uuid, zone_uuid)
+ thr["_t"] = time.time()
+ self._cache[str(camera_id)] = thr
+ except Exception as exc: # noqa: BLE001 β never let threshold refresh break the loop
+ logger.debug("adaptive_thresholds.refresh failed for %s: %s", camera_id, exc)
+
+
+adaptive_thresholds = AdaptiveThresholds()
diff --git a/backend/services/alpr_service.py b/backend/services/alpr_service.py
new file mode 100644
index 0000000..b86120d
--- /dev/null
+++ b/backend/services/alpr_service.py
@@ -0,0 +1,102 @@
+"""Real local ALPR (Automatic License-Plate Recognition) via EasyOCR.
+
+Replaces the slow, inconsistent vision-model plate path with a fast, local OCR
+engine. Given a vehicle crop it reads candidate text, keeps the most plate-like
+token (alphanumeric, plate-length), normalises it, and returns it for BOLO /
+watchlist matching (`bolo_service.check_plate_match`) and VehicleSighting
+logging. Lazy-loaded and gated by ALPR_ENABLED so it never affects startup; runs
+on CPU by default to avoid GPU contention with Ollama.
+"""
+from __future__ import annotations
+
+import logging
+import re
+from typing import Any, Dict, List, Optional, Sequence
+
+logger = logging.getLogger(__name__)
+
+_reader = None
+_reader_lock = None
+_PLATE_RE = re.compile(r"^[A-Z0-9]{4,9}$")
+_ALNUM = re.compile(r"[^A-Z0-9]")
+
+
+def _normalise(text: str) -> str:
+ return _ALNUM.sub("", (text or "").upper())
+
+
+class ALPRService:
+ def _get_reader(self):
+ global _reader, _reader_lock
+ from backend.config import settings
+ if not getattr(settings, "ALPR_ENABLED", True):
+ return None
+ import threading
+ if _reader_lock is None:
+ _reader_lock = threading.Lock()
+ with _reader_lock:
+ if _reader is None:
+ try:
+ import easyocr
+ use_gpu = bool(getattr(settings, "ALPR_GPU", False))
+ _reader = easyocr.Reader(["en"], gpu=use_gpu, verbose=False)
+ logger.info("ALPR (EasyOCR) reader loaded (gpu=%s)", use_gpu)
+ except Exception as exc: # noqa: BLE001
+ logger.warning("ALPR reader load failed: %s", exc)
+ _reader = None
+ return _reader
+
+ def available(self) -> bool:
+ return self._get_reader() is not None
+
+ def read_plate(self, frame_bgr, bbox: Optional[Sequence[float]] = None) -> Optional[Dict[str, Any]]:
+ """Read the most plate-like text from a (vehicle) crop.
+
+ Returns {plate, confidence, raw} for the best candidate, or None.
+ """
+ reader = self._get_reader()
+ if reader is None or frame_bgr is None:
+ return None
+ from backend.config import settings
+ min_conf = float(getattr(settings, "ALPR_MIN_CONFIDENCE", 0.4))
+
+ crop = frame_bgr
+ try:
+ if bbox is not None:
+ x1, y1, x2, y2 = [int(v) for v in bbox]
+ crop = frame_bgr[max(0, y1):max(0, y2), max(0, x1):max(0, x2)]
+ if crop is None or crop.size == 0:
+ return None
+ detections = reader.readtext(crop) # [(box, text, conf), ...]
+ except Exception as exc: # noqa: BLE001
+ logger.debug("ALPR readtext failed: %s", exc)
+ return None
+
+ best: Optional[Dict[str, Any]] = None
+ for det in detections or []:
+ try:
+ _box, text, conf = det
+ except Exception:
+ continue
+ norm = _normalise(text)
+ if not _PLATE_RE.match(norm) or conf < min_conf:
+ continue
+ if best is None or conf > best["confidence"]:
+ best = {"plate": norm, "confidence": round(float(conf), 3), "raw": text}
+ return best
+
+ async def read_and_match(self, frame_bgr, bbox=None) -> Dict[str, Any]:
+ """Read a plate and check it against active vehicle BOLOs."""
+ result = self.read_plate(frame_bgr, bbox)
+ if not result:
+ return {"plate": None, "matches": []}
+ try:
+ from backend.services.bolo_service import bolo_service
+ matches = await bolo_service.check_plate_match(result["plate"])
+ except Exception as exc: # noqa: BLE001
+ logger.debug("ALPR BOLO match failed: %s", exc)
+ matches = []
+ return {**result, "matches": matches}
+
+
+alpr_service = ALPRService()
diff --git a/backend/services/audio_detection_service.py b/backend/services/audio_detection_service.py
new file mode 100644
index 0000000..2b90f98
--- /dev/null
+++ b/backend/services/audio_detection_service.py
@@ -0,0 +1,129 @@
+"""Real audio event detection (DSP) β replaces the video-inference stub.
+
+The audio sentinel previously *inferred* sound from the video scene (a placeholder
+β there was no audio at all). This analyses ACTUAL audio: it extracts acoustic
+features with librosa and classifies high-value security sounds by their physical
+signature:
+
+ - gunshot : a loud, broadband, impulsive transient (sharp onset, high flatness)
+ - glass_break : a high-frequency burst (very high spectral centroid + ZCR)
+ - scream : sustained, loud, high-pitched harmonic energy (low flatness)
+ - alarm/siren : a sustained narrow-band tone (very low flatness, stable pitch)
+
+Deterministic and dependency-light (librosa only). `classify()` is the pluggable
+seam β a deep model (PANNs/YAMNet) can replace the heuristic later without
+changing callers. Audio ingestion (ffmpeg from an RTSP audio track or a mic) is
+provided but needs a real source; the analysis path is unit-tested on buffers.
+"""
+from __future__ import annotations
+
+import io
+import logging
+from typing import Any, Dict, List, Optional
+
+import numpy as np
+
+logger = logging.getLogger(__name__)
+
+
+def _features(y: np.ndarray, sr: int) -> Dict[str, float]:
+ import librosa
+ if y.size == 0:
+ return {}
+ y = y.astype(np.float32)
+ hop = 512
+ rms = librosa.feature.rms(y=y, hop_length=hop)[0]
+ onset = librosa.onset.onset_strength(y=y, sr=sr)
+
+ # Spectral character is computed on the LOUDEST window, not averaged over the
+ # whole (often mostly-silent) buffer β otherwise a short burst is diluted.
+ seg = y
+ if rms.size:
+ peak_frame = int(np.argmax(rms))
+ c = peak_frame * hop
+ half = max(int(0.15 * sr), 256)
+ seg = y[max(0, c - half): c + half]
+ if seg.size < 256:
+ seg = y
+
+ cent = librosa.feature.spectral_centroid(y=seg, sr=sr)[0]
+ flat = librosa.feature.spectral_flatness(y=seg)[0]
+ zcr = librosa.feature.zero_crossing_rate(y=seg)[0]
+ return {
+ "peak_rms": float(np.max(rms)) if rms.size else 0.0,
+ "mean_rms": float(np.mean(rms)) if rms.size else 0.0,
+ "centroid_hz": float(np.mean(cent)),
+ "flatness": float(np.mean(flat)),
+ "zcr": float(np.mean(zcr)),
+ "onset_peak": float(np.max(onset)) if onset.size else 0.0,
+ "duration_s": float(len(y) / sr),
+ }
+
+
+class AudioDetectionService:
+ # Loudness gate β ignore quiet ambient audio entirely.
+ _RMS_GATE = 0.05
+
+ def classify(self, y: np.ndarray, sr: int) -> List[Dict[str, Any]]:
+ """Classify a mono audio buffer into security-relevant sound events."""
+ f = _features(y, sr)
+ if not f or f["peak_rms"] < self._RMS_GATE:
+ return []
+
+ events: List[Dict[str, Any]] = []
+ peak, cent, flat, zcr, onset = (
+ f["peak_rms"], f["centroid_hz"], f["flatness"], f["zcr"], f["onset_peak"]
+ )
+
+ # Very high-frequency burst β breaking glass (checked first; its high
+ # centroid is the distinguishing signature vs a broadband gunshot).
+ if cent >= 5000 and zcr >= 0.15:
+ events.append({"label": "glass_break", "severity": "high",
+ "confidence": round(min(0.9, 0.4 + cent / 12000 + zcr), 2)})
+ # Loud, broadband, impulsive β gunshot.
+ elif peak >= 0.25 and flat >= 0.20 and onset >= 1.0:
+ events.append({"label": "gunshot", "severity": "critical",
+ "confidence": round(min(0.95, 0.5 + flat + peak / 2), 2)})
+ # Sustained, loud, high-pitched, harmonic β scream/shout.
+ elif peak >= 0.15 and 1200 <= cent <= 4500 and flat < 0.12 and f["duration_s"] >= 0.4:
+ events.append({"label": "scream", "severity": "high",
+ "confidence": round(min(0.9, 0.4 + peak), 2)})
+ # Sustained narrow-band tone β alarm / siren.
+ elif flat < 0.02 and f["mean_rms"] >= 0.1:
+ events.append({"label": "alarm", "severity": "medium",
+ "confidence": round(min(0.9, 0.5 + (0.02 - flat) * 10), 2)})
+
+ for e in events:
+ e["features"] = {k: round(v, 4) for k, v in f.items()}
+ return events
+
+ def analyze_bytes(self, wav_bytes: bytes) -> List[Dict[str, Any]]:
+ try:
+ import soundfile as sf
+ y, sr = sf.read(io.BytesIO(wav_bytes), dtype="float32")
+ if y.ndim > 1:
+ y = y.mean(axis=1) # mono
+ return self.classify(y, sr)
+ except Exception as exc: # noqa: BLE001
+ logger.warning("audio analyze_bytes failed: %s", exc)
+ return []
+
+ def extract_audio_from_rtsp(self, url: str, seconds: float = 3.0, sr: int = 16000) -> Optional[np.ndarray]:
+ """Pull a few seconds of audio from an RTSP stream via ffmpeg (needs an
+ audio track on the stream). Returns a mono float32 buffer, or None."""
+ import subprocess
+ try:
+ proc = subprocess.run(
+ ["ffmpeg", "-i", url, "-t", str(seconds), "-ac", "1", "-ar", str(sr),
+ "-f", "f32le", "-loglevel", "quiet", "pipe:1"],
+ capture_output=True, timeout=seconds + 10,
+ )
+ if proc.returncode != 0 or not proc.stdout:
+ return None
+ return np.frombuffer(proc.stdout, dtype=np.float32)
+ except Exception as exc: # noqa: BLE001
+ logger.debug("rtsp audio extract failed: %s", exc)
+ return None
+
+
+audio_detection_service = AudioDetectionService()
diff --git a/backend/services/bolo_matcher.py b/backend/services/bolo_matcher.py
new file mode 100644
index 0000000..18c6603
--- /dev/null
+++ b/backend/services/bolo_matcher.py
@@ -0,0 +1,108 @@
+"""Real-time BOLO appearance matching.
+
+`bolo_service` already does vehicle-plate matching (Levenshtein). This adds the
+missing half: matching detected PEOPLE against active person BOLOs by CLIP
+appearance embedding. Active person BOLOs (with an enrolled appearance embedding
+stored in their JSONB ``description``) are cached in memory and refreshed on a
+throttle; every new person track is embedded once (reusing
+`appearance_embedder.appearance_embedding`) and matched by cosine similarity.
+
+A hit becomes a high-severity threat that flows through the normal (verified)
+alert path β turning the BOLO list from a static database into an active watch.
+"""
+from __future__ import annotations
+
+import logging
+import time
+from typing import Any, Dict, List, Optional
+
+logger = logging.getLogger(__name__)
+
+_TTL_SECONDS = 300.0
+
+
+class BoloMatcher:
+ def __init__(self) -> None:
+ self._cache: List[Dict[str, Any]] = [] # {bolo_id, embedding, severity, reason}
+ self._cache_t: float = 0.0
+ self._checked: Dict[str, set] = {} # camera_id -> track_ids already scanned
+
+ def has_active(self) -> bool:
+ return bool(self._cache) and (time.time() - self._cache_t) <= _TTL_SECONDS
+
+ async def refresh(self, db) -> None:
+ """Load active person BOLOs (with an enrolled appearance embedding)."""
+ try:
+ from backend.services.bolo_service import bolo_service
+ bolos = await bolo_service.get_active_bolos(bolo_type="person")
+ except Exception as exc: # noqa: BLE001
+ logger.debug("bolo_matcher.refresh failed: %s", exc)
+ return
+ cache: List[Dict[str, Any]] = []
+ for b in bolos or []:
+ desc = b.get("description") or {}
+ emb = desc.get("appearance_embedding") if isinstance(desc, dict) else None
+ if emb:
+ cache.append({
+ "bolo_id": b.get("id"),
+ "embedding": emb,
+ "severity": b.get("severity", "high"),
+ "reason": b.get("reason") or "BOLO appearance match",
+ })
+ self._cache = cache
+ self._cache_t = time.time()
+
+ def match(self, embedding: List[float], threshold: float = 0.82) -> List[Dict[str, Any]]:
+ from backend.services.appearance_embedder import cosine_similarity
+ out: List[Dict[str, Any]] = []
+ for entry in self._cache:
+ sim = cosine_similarity(embedding, entry["embedding"])
+ if sim >= threshold:
+ out.append({**entry, "similarity": sim})
+ out.sort(key=lambda m: m["similarity"], reverse=True)
+ return out
+
+ def scan_frame(self, frame_bgr, detections: Dict[str, Any], camera_id: str,
+ threshold: Optional[float] = None) -> List[Dict[str, Any]]:
+ """Embed new person tracks and return BOLO-match threats."""
+ if not self.has_active():
+ return []
+ try:
+ from backend.config import settings
+ thr = threshold if threshold is not None else float(getattr(settings, "BOLO_MATCH_THRESHOLD", 0.82))
+ except Exception:
+ thr = threshold if threshold is not None else 0.82
+ from backend.services.appearance_embedder import appearance_embedding
+
+ checked = self._checked.setdefault(str(camera_id), set())
+ present = {d.get("track_id") for d in detections.get("detections", [])}
+ # prune track_ids no longer present so a returning subject re-scans
+ checked &= present
+
+ out: List[Dict[str, Any]] = []
+ for d in detections.get("detections", []) if isinstance(detections, dict) else []:
+ if (d.get("class") or "").lower() != "person":
+ continue
+ tid = d.get("track_id")
+ if tid is None or tid in checked or not d.get("bbox"):
+ continue
+ checked.add(tid)
+ emb = appearance_embedding(frame_bgr, d["bbox"])
+ if not emb:
+ continue
+ matches = self.match(emb, thr)
+ if matches:
+ best = matches[0]
+ out.append({
+ "signature": "bolo_person_match",
+ "description": f"appearance matches active BOLO ({best['reason']})",
+ "severity": best.get("severity", "high"),
+ "confidence": round(float(best["similarity"]), 2),
+ "detection_method": "bolo",
+ "track_id": tid,
+ "bolo_id": str(best.get("bolo_id")),
+ })
+ return out
+
+
+bolo_matcher = BoloMatcher()
diff --git a/backend/services/bolo_service.py b/backend/services/bolo_service.py
index 08a76ae..d93fbd2 100644
--- a/backend/services/bolo_service.py
+++ b/backend/services/bolo_service.py
@@ -192,6 +192,31 @@ async def deactivate_bolo(self, bolo_id: uuid.UUID) -> bool:
logger.error("Failed to deactivate BOLO %s: %s", bolo_id, exc, exc_info=True)
return False
+ async def enroll_appearance(self, bolo_id: uuid.UUID, embedding: List[float]) -> bool:
+ """Store a CLIP appearance embedding on a person BOLO (in description JSONB)
+ so the real-time matcher can match detected people against it."""
+ try:
+ from backend.database import async_session
+ from backend.models.phase2_models import BOLOEntry
+
+ async with async_session() as session:
+ entry = await session.get(BOLOEntry, bolo_id)
+ if entry is None:
+ return False
+ desc = dict(entry.description or {})
+ desc["appearance_embedding"] = list(embedding)
+ entry.description = desc
+ # JSONB needs an explicit reassignment to be flagged dirty
+ from sqlalchemy.orm.attributes import flag_modified
+ flag_modified(entry, "description")
+ entry.updated_at = datetime.now(timezone.utc)
+ await session.commit()
+ logger.info("BOLO %s enrolled with %d-d appearance embedding", bolo_id, len(embedding))
+ return True
+ except Exception as exc:
+ logger.error("Failed to enroll BOLO appearance %s: %s", bolo_id, exc, exc_info=True)
+ return False
+
# ββ Fuzzy plate matching βββββββββββββββββββββββββββββββββββββ
async def check_plate_match(self, plate_text: str) -> List[Dict[str, Any]]:
diff --git a/backend/services/compliance_dashboard_service.py b/backend/services/compliance_dashboard_service.py
index 4fe283b..0710c9e 100644
--- a/backend/services/compliance_dashboard_service.py
+++ b/backend/services/compliance_dashboard_service.py
@@ -715,6 +715,53 @@ async def get_compliance_history(
for r in rows
]
+ async def forecast_compliance(
+ self, db: AsyncSession, framework: str = "gdpr", target: float = 0.8, limit: int = 12,
+ ) -> Dict[str, Any]:
+ """Project the compliance score forward from its recent trend and estimate
+ when (if ever) it will fall below `target` β proactive vs. reactive."""
+ history = await self.get_compliance_history(db, framework=framework, limit=limit)
+ pts = []
+ for h in history:
+ if h.get("assessed_at") and h.get("overall_score") is not None:
+ try:
+ pts.append((datetime.fromisoformat(h["assessed_at"]), float(h["overall_score"])))
+ except Exception:
+ continue
+ pts.sort(key=lambda p: p[0])
+ if len(pts) < 3:
+ return {"framework": framework, "status": "insufficient_history", "sample_count": len(pts)}
+
+ t0 = pts[0][0]
+ xs = [(p[0] - t0).total_seconds() / 86400.0 for p in pts] # days
+ ys = [p[1] for p in pts]
+ n = len(xs)
+ mx, my = sum(xs) / n, sum(ys) / n
+ denom = sum((x - mx) ** 2 for x in xs) or 1e-9
+ slope = sum((xs[i] - mx) * (ys[i] - my) for i in range(n)) / denom # score/day
+ current = ys[-1]
+
+ breach_in_days = None
+ if slope < -1e-6 and current > target:
+ breach_in_days = round((current - target) / (-slope), 1)
+ projected_30d = round(max(0.0, min(1.0, current + slope * 30)), 3)
+
+ if current < target:
+ status = "below_target"
+ elif breach_in_days is not None and breach_in_days <= 30:
+ status = "at_risk"
+ else:
+ status = "on_track"
+ return {
+ "framework": framework, "target": target,
+ "current_score": round(current, 3),
+ "trend_per_day": round(slope, 5),
+ "projected_breach_in_days": breach_in_days,
+ "projected_30d_score": projected_30d,
+ "status": status,
+ "sample_count": n,
+ }
+
async def get_issues(
self, db: AsyncSession, severity: Optional[str] = None,
) -> List[Dict[str, Any]]:
diff --git a/backend/services/escalation_tracker.py b/backend/services/escalation_tracker.py
new file mode 100644
index 0000000..29a0747
--- /dev/null
+++ b/backend/services/escalation_tracker.py
@@ -0,0 +1,85 @@
+"""Per-entity threat-escalation chains.
+
+Individual behaviours (loitering, running, a fall, a BOLO hit) are scored in
+isolation by the detection layer. But a *sequence* on the same entity β
+loiter β test-a-door β approach β is far more telling than any single step.
+This tracker ingests the track-bearing threats already produced each frame,
+maintains a short per-(camera, track) history, and emits a CRITICAL
+``escalation_chain`` threat when an entity shows several DISTINCT, escalating
+behaviours inside a time window β turning a string of low/medium signals into one
+high-confidence proactive warning.
+"""
+from __future__ import annotations
+
+import logging
+from collections import deque
+from typing import Any, Deque, Dict, List, Tuple
+
+logger = logging.getLogger(__name__)
+
+# Behaviour β escalation rank (higher = closer to a hostile act).
+_RANK: Dict[str, int] = {
+ "loitering": 1, "tailgating": 2, "running": 2, "abandoned_object": 2,
+ "tamper": 2, "evasive": 2, "intrusion": 3, "fall": 3, "bolo_person_match": 3,
+ "concealed_carry": 4, "pre_assault": 4, "weapon": 4, "aggression": 4,
+}
+
+_WINDOW_S = 300.0 # escalation steps must fall within this window
+_MIN_STEPS = 3 # distinct behaviours to count as a chain
+_COOLDOWN_S = 120.0 # don't re-fire a chain for the same track this often
+
+
+class EscalationTracker:
+ def __init__(self) -> None:
+ self._tracks: Dict[Tuple[str, Any], Deque[Tuple[float, str, int]]] = {}
+ self._cooldowns: Dict[Tuple[str, Any], float] = {}
+
+ def observe(self, camera_id: str, threats: List[Dict[str, Any]], t: float) -> List[Dict[str, Any]]:
+ """Record this frame's threats and return any escalation_chain threats."""
+ out: List[Dict[str, Any]] = []
+ for thr in threats or []:
+ tid = thr.get("track_id")
+ sig = thr.get("signature")
+ if tid is None or not sig or sig == "escalation_chain":
+ continue
+ rank = _RANK.get(sig)
+ if rank is None:
+ continue
+ key = (str(camera_id), tid)
+ dq = self._tracks.setdefault(key, deque())
+ dq.append((t, sig, rank))
+ while dq and t - dq[0][0] > _WINDOW_S:
+ dq.popleft()
+
+ # distinct behaviours seen in the window (keep highest rank per sig)
+ distinct: Dict[str, int] = {}
+ for (_tt, s, r) in dq:
+ distinct[s] = max(distinct.get(s, 0), r)
+ if len(distinct) < _MIN_STEPS:
+ continue
+ ranks = sorted(distinct.values())
+ if max(ranks) <= min(ranks):
+ continue # no actual escalation, just repetition
+ if t - self._cooldowns.get(key, -1e9) < _COOLDOWN_S:
+ continue
+ self._cooldowns[key] = t
+ chain = " β ".join(s for s, _ in sorted(distinct.items(), key=lambda kv: kv[1]))
+ out.append({
+ "signature": "escalation_chain",
+ "description": f"behavioural escalation on track {tid}: {chain}",
+ "severity": "critical",
+ "confidence": round(min(0.95, 0.7 + 0.05 * len(distinct)), 2),
+ "detection_method": "escalation",
+ "track_id": tid,
+ })
+ logger.info("escalation_chain on camera %s track %s: %s", camera_id, tid, chain)
+ return out
+
+ def prune(self, camera_id: str, active_track_ids: set) -> None:
+ """Drop history for tracks no longer present (called opportunistically)."""
+ for key in [k for k in self._tracks if k[0] == str(camera_id) and k[1] not in active_track_ids]:
+ self._tracks.pop(key, None)
+ self._cooldowns.pop(key, None)
+
+
+escalation_tracker = EscalationTracker()
diff --git a/backend/services/forensic_search_service.py b/backend/services/forensic_search_service.py
index 771f1ae..1286bd4 100644
--- a/backend/services/forensic_search_service.py
+++ b/backend/services/forensic_search_service.py
@@ -120,6 +120,55 @@ async def search_by_similarity(self, query_text: str, top_k: int = 20,
logger.error("Similarity search failed: %s", e)
return {"query": query_text, "result_count": 0, "results": [], "error": str(e)}
+ async def search_objects_by_image(
+ self, frame_bgr, bbox=None, camera_id: str = None,
+ time_from=None, time_to=None, top_k: int = 20,
+ ) -> dict:
+ """Image-based "looks-like" search: given a query image (optionally a
+ bbox crop), find the most visually similar people/objects across cameras.
+
+ CLIP image and text embeddings share a space, so this reuses the same
+ ``object_crops`` collection that `search_objects_by_text` queries β just
+ embedding the pixels instead of a phrase. Degrades to an empty result set
+ when CLIP/Qdrant is unavailable.
+ """
+ try:
+ from backend.services.clip_embedder import clip_embedder
+ from backend.services.vector_store import vector_store
+ except Exception:
+ return {"query": "image", "result_count": 0, "results": []}
+
+ crop = frame_bgr
+ try:
+ if bbox is not None:
+ x1, y1, x2, y2 = [int(v) for v in bbox]
+ crop = frame_bgr[max(0, y1):max(0, y2), max(0, x1):max(0, x2)]
+ vec = clip_embedder.embed_frame_sync(crop) if crop is not None and crop.size else []
+ vec = list(vec) if vec else []
+ except Exception:
+ vec = []
+ if not vec:
+ return {"query": "image", "result_count": 0, "results": []}
+
+ filters = {"camera_id": str(camera_id)} if camera_id else None
+ hits = await vector_store.search_by_vector(
+ vector=vec, top_k=max(int(top_k), 1), filters=filters, collection="object_crops",
+ )
+ tf, tt = _to_epoch(time_from), _to_epoch(time_to)
+ results = []
+ for h in hits or []:
+ ts = h.get("timestamp")
+ if tf is not None and ts is not None and ts < tf:
+ continue
+ if tt is not None and ts is not None and ts > tt:
+ continue
+ results.append({
+ "entity_id": h.get("entity_id"), "camera_id": h.get("camera_id"),
+ "zone_id": h.get("zone_id"), "timestamp": ts,
+ "behavior": h.get("behavior"), "score": h.get("score"),
+ })
+ return {"query": "image", "result_count": len(results), "results": results}
+
async def search_objects_by_text(
self, query_text: str, camera_id: str = None,
time_from=None, time_to=None, top_k: int = 20,
diff --git a/backend/services/temporal_behavior.py b/backend/services/temporal_behavior.py
index 4c24f79..68f3de6 100644
--- a/backend/services/temporal_behavior.py
+++ b/backend/services/temporal_behavior.py
@@ -181,11 +181,16 @@ def _person_behaviours(self, cam_id, tid, st, t, w_frame, h_frame, diag) -> List
f"person track {tid}: sustained speed {speed_frac:.2f} frame-widths/s", tid))
# --- Loitering: long dwell in a small area ---
+ # The dwell threshold is learned per camera/zone/time-slot when a baseline
+ # exists (cuts false positives in naturally-busy areas); falls back to the
+ # static _LOITER_S otherwise.
+ from backend.services.adaptive_thresholds import adaptive_thresholds
+ loiter_s = adaptive_thresholds.get(cam_id, "dwell_time_threshold", _LOITER_S)
dwell = st.last_seen - st.first_seen
- if dwell >= _LOITER_S:
+ if dwell >= loiter_s:
spread = self._spatial_spread(hist) / diag
if spread <= _LOITER_RADIUS_FRAC and self._fire(st, "loitering", t):
- conf = min(0.95, 0.5 + (dwell - _LOITER_S) / max(_LOITER_S, 1) * 0.4)
+ conf = min(0.95, 0.5 + (dwell - loiter_s) / max(loiter_s, 1) * 0.4)
out.append(self._mk("loitering", "low", round(conf, 2),
f"person track {tid}: dwelling {int(dwell)}s within a small area (spread {spread:.2f})", tid))
return out
diff --git a/backend/services/trajectory_predictor.py b/backend/services/trajectory_predictor.py
new file mode 100644
index 0000000..8c4b25e
--- /dev/null
+++ b/backend/services/trajectory_predictor.py
@@ -0,0 +1,77 @@
+"""Trajectory prediction β turn past motion into a near-future forecast.
+
+The tracker records where entities have BEEN; this predicts where they are
+GOING. A constant-velocity estimate (least-squares over recent positions, robust
+to per-frame jitter) yields the predicted position at a horizon, current speed
+and heading, and the ETA at which the path passes closest to a point of interest
+(e.g. a secure door) β enabling proactive alerts ("if this person continues they
+reach the server-room door in ~8s") instead of purely reactive ones.
+"""
+from __future__ import annotations
+
+import math
+from typing import Any, Dict, List, Optional, Sequence, Tuple
+
+
+def _velocity(points: Sequence[Tuple[float, float, float]]) -> Tuple[float, float, float, float]:
+ """Least-squares constant velocity over (t, x, y). Returns (x0,y0,vx,vy) at the latest t."""
+ n = len(points)
+ t0 = points[-1][0]
+ ts = [p[0] - t0 for p in points] # center on latest sample
+ mean_t = sum(ts) / n
+ denom = sum((t - mean_t) ** 2 for t in ts) or 1e-6
+
+ def slope_intercept(vals):
+ mean_v = sum(vals) / n
+ slope = sum((ts[i] - mean_t) * (vals[i] - mean_v) for i in range(n)) / denom
+ intercept = mean_v - slope * mean_t # value at t==0 (i.e. latest time)
+ return slope, intercept
+
+ vx, x0 = slope_intercept([p[1] for p in points])
+ vy, y0 = slope_intercept([p[2] for p in points])
+ return x0, y0, vx, vy
+
+
+def predict(history: Sequence[Tuple[float, float, float]], horizon_s: float = 5.0) -> Optional[Dict[str, Any]]:
+ """Predict motion from (t, x, y) history.
+
+ Returns {position:[x,y], velocity:[vx,vy], speed, heading_deg} for `horizon_s`
+ into the future, or None if there isn't enough history.
+ """
+ pts = [(float(t), float(x), float(y)) for (t, x, y) in history if t is not None]
+ if len(pts) < 3:
+ return None
+ pts = pts[-12:] # recent window only
+ x0, y0, vx, vy = _velocity(pts)
+ px, py = x0 + vx * horizon_s, y0 + vy * horizon_s
+ speed = math.hypot(vx, vy)
+ heading = (math.degrees(math.atan2(vy, vx)) + 360.0) % 360.0
+ return {
+ "position": [round(px, 1), round(py, 1)],
+ "velocity": [round(vx, 3), round(vy, 3)],
+ "speed": round(speed, 3),
+ "heading_deg": round(heading, 1),
+ "current": [round(x0, 1), round(y0, 1)],
+ }
+
+
+def eta_to_point(history: Sequence[Tuple[float, float, float]], target: Tuple[float, float],
+ max_horizon_s: float = 30.0, hit_radius: float = 40.0) -> Optional[float]:
+ """Seconds until the predicted path comes within `hit_radius` of `target`,
+ or None if it never does within `max_horizon_s` (or motion is too slow)."""
+ pred = predict(history, horizon_s=0.0)
+ if pred is None:
+ return None
+ x0, y0 = pred["current"]
+ vx, vy = pred["velocity"]
+ if math.hypot(vx, vy) < 1e-3:
+ return None
+ # sample the predicted ray; cheap and robust
+ step = 0.25
+ t = 0.0
+ while t <= max_horizon_s:
+ x, y = x0 + vx * t, y0 + vy * t
+ if math.hypot(x - target[0], y - target[1]) <= hit_radius:
+ return round(t, 2)
+ t += step
+ return None
diff --git a/frontend/src/app/admin/page.tsx b/frontend/src/app/admin/page.tsx
index 00d3f96..64cb143 100644
--- a/frontend/src/app/admin/page.tsx
+++ b/frontend/src/app/admin/page.tsx
@@ -69,7 +69,7 @@ const PLAN_LABELS: Record