feat: initial BantayCam scaffold

- FastAPI backend with async SQLAlchemy
- Camera RTSP management (add, start, stop)
- Vehicle detection (YOLO + fast-alpr)
  - Type: car, motorcycle, truck, jeepney
  - Color detection (HSV)
  - License plate OCR
  - Motorcycle person count
- Face detection + InsightFace ArcFace embedding
- pgvector identity grouping (auto-cluster same face)
- Vehicle + person movement trail APIs
- Docker Compose with pgvector/pg16
- Models: Camera, VehicleIdentity, VehicleEvent, PersonIdentity, FaceEvent
This commit is contained in:
Nemo
2026-03-12 12:11:02 +08:00
commit 7c43e4580d
24 changed files with 1650 additions and 0 deletions

0
backend/app/__init__.py Normal file
View File

View File

118
backend/app/api/cameras.py Normal file
View File

@@ -0,0 +1,118 @@
"""
Camera API — Register, manage, and monitor RTSP cameras.
"""
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from pydantic import BaseModel
from typing import Optional
from uuid import UUID
from app.core.database import get_db
from app.models.camera import Camera, CameraStatus
from app.services.stream_manager import StreamManager
from app.workers.pipeline import DetectionPipeline
router = APIRouter(prefix="/cameras", tags=["cameras"])
class CameraCreate(BaseModel):
name: str
rtsp_url: str
location: Optional[str] = None
description: Optional[str] = None
auto_start: bool = True
class CameraResponse(BaseModel):
id: UUID
name: str
rtsp_url: str
location: Optional[str]
description: Optional[str]
status: str
is_enabled: bool
class Config:
from_attributes = True
@router.get("/", response_model=list[CameraResponse])
async def list_cameras(db: AsyncSession = Depends(get_db)):
result = await db.execute(select(Camera).order_by(Camera.name))
return result.scalars().all()
@router.post("/", response_model=CameraResponse, status_code=status.HTTP_201_CREATED)
async def add_camera(payload: CameraCreate, db: AsyncSession = Depends(get_db)):
"""Register a new RTSP camera and optionally start streaming."""
# Check duplicate name
existing = await db.execute(select(Camera).where(Camera.name == payload.name))
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail="Camera name already exists")
camera = Camera(
name=payload.name,
rtsp_url=payload.rtsp_url,
location=payload.location,
description=payload.description,
status=CameraStatus.inactive,
)
db.add(camera)
await db.commit()
await db.refresh(camera)
if payload.auto_start:
await _start_camera(camera)
return camera
@router.post("/{camera_id}/start")
async def start_camera(camera_id: UUID, db: AsyncSession = Depends(get_db)):
camera = await _get_camera(db, camera_id)
await _start_camera(camera)
camera.status = CameraStatus.active
await db.commit()
return {"message": f"Camera '{camera.name}' started"}
@router.post("/{camera_id}/stop")
async def stop_camera(camera_id: UUID, db: AsyncSession = Depends(get_db)):
camera = await _get_camera(db, camera_id)
manager = StreamManager.get()
await manager.stop_stream(camera_id)
camera.status = CameraStatus.inactive
await db.commit()
return {"message": f"Camera '{camera.name}' stopped"}
@router.delete("/{camera_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_camera(camera_id: UUID, db: AsyncSession = Depends(get_db)):
camera = await _get_camera(db, camera_id)
await StreamManager.get().stop_stream(camera_id)
await db.delete(camera)
await db.commit()
@router.get("/streams/status")
async def stream_status():
"""Get real-time status of all active streams."""
return StreamManager.get().get_all_status()
# --- Helpers ---
async def _get_camera(db: AsyncSession, camera_id: UUID) -> Camera:
result = await db.execute(select(Camera).where(Camera.id == camera_id))
camera = result.scalar_one_or_none()
if not camera:
raise HTTPException(status_code=404, detail="Camera not found")
return camera
async def _start_camera(camera: Camera):
manager = StreamManager.get()
worker = await manager.start_stream(camera.id, camera.name, camera.rtsp_url)
pipeline = DetectionPipeline(camera.id, camera.name, worker)
await pipeline.start()

130
backend/app/api/persons.py Normal file
View File

@@ -0,0 +1,130 @@
"""
Person API — Face identities and sighting trails.
See everywhere a face was detected, which cameras, and when.
"""
from fastapi import APIRouter, Depends, Query, UploadFile, File
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from pydantic import BaseModel
from typing import Optional
from uuid import UUID
from datetime import datetime
from app.core.database import get_db
from app.models.person import PersonIdentity, FaceEvent
router = APIRouter(prefix="/persons", tags=["persons"])
class PersonIdentityResponse(BaseModel):
id: UUID
name: Optional[str]
label: Optional[str]
is_registered: int
is_watchlisted: int
thumbnail_path: Optional[str]
first_seen_at: Optional[datetime]
last_seen_at: Optional[datetime]
total_sightings: int
class Config:
from_attributes = True
@router.get("/identities", response_model=list[PersonIdentityResponse])
async def list_identities(
name: Optional[str] = Query(None),
limit: int = Query(50, le=200),
db: AsyncSession = Depends(get_db),
):
q = select(PersonIdentity).order_by(desc(PersonIdentity.last_seen_at)).limit(limit)
if name:
q = q.where(PersonIdentity.name.ilike(f"%{name}%"))
result = await db.execute(q)
return result.scalars().all()
@router.get("/identities/{identity_id}/trail")
async def person_trail(identity_id: UUID, db: AsyncSession = Depends(get_db)):
"""
Full sighting trail — every camera this face appeared on and when.
Groups by camera to show movement pattern.
"""
result = await db.execute(
select(FaceEvent)
.where(FaceEvent.identity_id == identity_id)
.order_by(desc(FaceEvent.captured_at))
.limit(500)
)
events = result.scalars().all()
return {
"identity_id": str(identity_id),
"total_sightings": len(events),
"trail": [
{
"camera_id": str(e.camera_id),
"captured_at": e.captured_at,
"similarity_score": e.similarity_score,
"face_snapshot_path": e.face_snapshot_path,
"snapshot_path": e.snapshot_path,
"vehicle_event_id": str(e.vehicle_event_id) if e.vehicle_event_id else None,
}
for e in events
]
}
@router.patch("/identities/{identity_id}")
async def register_identity(
identity_id: UUID,
name: Optional[str] = None,
label: Optional[str] = None,
is_watchlisted: Optional[int] = None,
notes: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
"""Give a name/label to an auto-detected unknown identity."""
result = await db.execute(select(PersonIdentity).where(PersonIdentity.id == identity_id))
identity = result.scalar_one_or_none()
if not identity:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Person identity not found")
if name is not None:
identity.name = name
identity.is_registered = 1
if label is not None:
identity.label = label
if is_watchlisted is not None:
identity.is_watchlisted = is_watchlisted
await db.commit()
return {"message": "Identity registered"}
@router.get("/events")
async def list_face_events(
camera_id: Optional[UUID] = Query(None),
identity_id: Optional[UUID] = Query(None),
limit: int = Query(100, le=500),
db: AsyncSession = Depends(get_db),
):
q = select(FaceEvent).order_by(desc(FaceEvent.captured_at)).limit(limit)
if camera_id:
q = q.where(FaceEvent.camera_id == camera_id)
if identity_id:
q = q.where(FaceEvent.identity_id == identity_id)
result = await db.execute(q)
events = result.scalars().all()
return [
{
"id": str(e.id),
"camera_id": str(e.camera_id),
"identity_id": str(e.identity_id) if e.identity_id else None,
"detection_confidence": e.detection_confidence,
"similarity_score": e.similarity_score,
"face_snapshot_path": e.face_snapshot_path,
"captured_at": e.captured_at,
}
for e in events
]

139
backend/app/api/vehicles.py Normal file
View File

@@ -0,0 +1,139 @@
"""
Vehicle API — Query vehicle events and identities.
See where a plate has been seen across all cameras and when.
"""
from fastapi import APIRouter, Depends, Query
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, desc
from pydantic import BaseModel
from typing import Optional
from uuid import UUID
from datetime import datetime
from app.core.database import get_db
from app.models.vehicle import VehicleEvent, VehicleIdentity
router = APIRouter(prefix="/vehicles", tags=["vehicles"])
class VehicleIdentityResponse(BaseModel):
id: UUID
plate_number: Optional[str]
vehicle_type: str
color: Optional[str]
label: Optional[str]
is_whitelisted: int
is_blacklisted: int
first_seen_at: Optional[datetime]
last_seen_at: Optional[datetime]
total_sightings: int
class Config:
from_attributes = True
class VehicleEventResponse(BaseModel):
id: UUID
camera_id: UUID
plate_number: Optional[str]
vehicle_type: str
color: Optional[str]
person_count: Optional[int]
detection_confidence: Optional[float]
snapshot_path: Optional[str]
plate_snapshot_path: Optional[str]
captured_at: datetime
class Config:
from_attributes = True
@router.get("/identities", response_model=list[VehicleIdentityResponse])
async def list_vehicle_identities(
plate: Optional[str] = Query(None),
vehicle_type: Optional[str] = Query(None),
limit: int = Query(50, le=200),
db: AsyncSession = Depends(get_db),
):
q = select(VehicleIdentity).order_by(desc(VehicleIdentity.last_seen_at)).limit(limit)
if plate:
q = q.where(VehicleIdentity.plate_number.ilike(f"%{plate}%"))
if vehicle_type:
q = q.where(VehicleIdentity.vehicle_type == vehicle_type)
result = await db.execute(q)
return result.scalars().all()
@router.get("/identities/{identity_id}/trail")
async def vehicle_trail(identity_id: UUID, db: AsyncSession = Depends(get_db)):
"""
Get full sighting trail for a vehicle — every camera it was seen on and when.
"""
result = await db.execute(
select(VehicleEvent)
.where(VehicleEvent.identity_id == identity_id)
.order_by(desc(VehicleEvent.captured_at))
.limit(500)
)
events = result.scalars().all()
return {
"identity_id": str(identity_id),
"total_sightings": len(events),
"trail": [
{
"camera_id": str(e.camera_id),
"captured_at": e.captured_at,
"plate_number": e.plate_number,
"color": e.color,
"vehicle_type": e.vehicle_type,
"person_count": e.person_count,
"snapshot_path": e.snapshot_path,
}
for e in events
]
}
@router.get("/events", response_model=list[VehicleEventResponse])
async def list_vehicle_events(
camera_id: Optional[UUID] = Query(None),
plate: Optional[str] = Query(None),
limit: int = Query(100, le=500),
db: AsyncSession = Depends(get_db),
):
q = select(VehicleEvent).order_by(desc(VehicleEvent.captured_at)).limit(limit)
if camera_id:
q = q.where(VehicleEvent.camera_id == camera_id)
if plate:
q = q.where(VehicleEvent.plate_number.ilike(f"%{plate}%"))
result = await db.execute(q)
return result.scalars().all()
@router.patch("/identities/{identity_id}")
async def update_vehicle_identity(
identity_id: UUID,
label: Optional[str] = None,
is_whitelisted: Optional[int] = None,
is_blacklisted: Optional[int] = None,
notes: Optional[str] = None,
db: AsyncSession = Depends(get_db),
):
"""Label a vehicle — mark as resident, delivery, blacklisted, etc."""
result = await db.execute(select(VehicleIdentity).where(VehicleIdentity.id == identity_id))
identity = result.scalar_one_or_none()
if not identity:
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Vehicle identity not found")
if label is not None:
identity.label = label
if is_whitelisted is not None:
identity.is_whitelisted = is_whitelisted
if is_blacklisted is not None:
identity.is_blacklisted = is_blacklisted
if notes is not None:
identity.notes = notes
await db.commit()
return {"message": "Updated"}

View File

View File

@@ -0,0 +1,42 @@
from pydantic_settings import BaseSettings
from typing import Optional
class Settings(BaseSettings):
APP_NAME: str = "BantayCam"
APP_VERSION: str = "0.1.0"
DEBUG: bool = False
# Database
DATABASE_URL: str = "postgresql+asyncpg://bantaycam:bantaycam@localhost:5432/bantaycam"
# Storage (MinIO / local)
STORAGE_TYPE: str = "local" # "local" or "minio"
STORAGE_PATH: str = "./snapshots"
MINIO_ENDPOINT: str = ""
MINIO_ACCESS_KEY: str = ""
MINIO_SECRET_KEY: str = ""
MINIO_BUCKET: str = "bantaycam"
# AI Models
YOLO_MODEL: str = "yolov8n.pt" # Vehicle + person detection
PLATE_MODEL: str = "yolov8n.pt" # Plate detection (custom PH)
FACE_MODEL: str = "buffalo_l" # InsightFace model
GPU_DEVICE: int = 0 # -1 for CPU, 0 for first GPU
# Processing
PROCESS_EVERY_N_FRAMES: int = 5 # Skip frames for performance
FACE_SIMILARITY_THRESHOLD: float = 0.5 # Cosine distance threshold
PLATE_CONFIDENCE_THRESHOLD: float = 0.6
DETECTION_CONFIDENCE_THRESHOLD: float = 0.5
# Alerts
TELEGRAM_BOT_TOKEN: Optional[str] = None
TELEGRAM_CHAT_ID: Optional[str] = None
class Config:
env_file = ".env"
case_sensitive = True
settings = Settings()

View File

@@ -0,0 +1,34 @@
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy.orm import DeclarativeBase
from app.core.config import settings
engine = create_async_engine(
settings.DATABASE_URL,
echo=settings.DEBUG,
pool_pre_ping=True,
)
AsyncSessionLocal = async_sessionmaker(
engine,
class_=AsyncSession,
expire_on_commit=False,
)
class Base(DeclarativeBase):
pass
async def get_db():
async with AsyncSessionLocal() as session:
try:
yield session
finally:
await session.close()
async def init_db():
"""Create all tables on startup."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)

91
backend/app/main.py Normal file
View File

@@ -0,0 +1,91 @@
"""
BantayCam — Integrated License Plate & Face Recognition for Philippine CCTV
FastAPI Application Entry Point
"""
from contextlib import asynccontextmanager
import logging
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from pathlib import Path
from app.core.config import settings
from app.core.database import init_db
from app.services.vehicle_detector import vehicle_detector
from app.services.face_service import face_service
from app.services.stream_manager import StreamManager
from app.api import cameras, vehicles, persons
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s"
)
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Startup and shutdown events."""
logger.info("🚀 BantayCam starting up...")
# Init database
await init_db()
logger.info("✅ Database initialized")
# Load AI models
logger.info("Loading AI models (this may take a moment)...")
vehicle_detector.load()
face_service.load()
logger.info("✅ AI models loaded")
# Ensure snapshot storage exists
Path(settings.STORAGE_PATH).mkdir(parents=True, exist_ok=True)
yield
# Shutdown
logger.info("Shutting down streams...")
await StreamManager.get().stop_all()
logger.info("👋 BantayCam shut down")
app = FastAPI(
title="BantayCam",
description="Integrated License Plate & Face Recognition for Philippine CCTV Infrastructure",
version=settings.APP_VERSION,
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"], # Tighten in production
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# API routes
app.include_router(cameras.router, prefix="/api/v1")
app.include_router(vehicles.router, prefix="/api/v1")
app.include_router(persons.router, prefix="/api/v1")
# Serve snapshots as static files
snapshot_dir = Path(settings.STORAGE_PATH)
snapshot_dir.mkdir(parents=True, exist_ok=True)
app.mount("/snapshots", StaticFiles(directory=str(snapshot_dir)), name="snapshots")
@app.get("/")
async def root():
return {
"app": settings.APP_NAME,
"version": settings.APP_VERSION,
"status": "running",
"docs": "/docs",
}
@app.get("/health")
async def health():
return {"status": "ok"}

View File

View File

@@ -0,0 +1,30 @@
from sqlalchemy import Column, String, Boolean, DateTime, Text, Enum
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
import enum
from app.core.database import Base
class CameraStatus(str, enum.Enum):
active = "active"
inactive = "inactive"
error = "error"
class Camera(Base):
__tablename__ = "cameras"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(255), nullable=False, unique=True) # e.g. "Gate 1 - Main Entrance"
rtsp_url = Column(Text, nullable=False) # rtsp://admin:pass@192.168.1.64:554/...
location = Column(String(255), nullable=True) # e.g. "North Gate, Building A"
description = Column(Text, nullable=True)
status = Column(Enum(CameraStatus), default=CameraStatus.inactive)
is_enabled = Column(Boolean, default=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
def __repr__(self):
return f"<Camera {self.name} ({self.status})>"

View File

@@ -0,0 +1,65 @@
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, JSON
from sqlalchemy.dialects.postgresql import UUID, ARRAY
from sqlalchemy.sql import func
from pgvector.sqlalchemy import Vector
import uuid
from app.core.database import Base
class PersonIdentity(Base):
"""
Unique person identity — grouped by face embedding similarity.
All face events linked to this identity let us trace where this person has been.
"""
__tablename__ = "person_identities"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
name = Column(String(255), nullable=True) # e.g. "Juan Dela Cruz" if registered
label = Column(String(100), nullable=True) # e.g. "Unit 3A Resident", "Delivery Rider"
notes = Column(Text, nullable=True)
is_registered = Column(Integer, default=0) # 1 = manually registered with name
is_watchlisted = Column(Integer, default=0) # 1 = flagged for alerts
thumbnail_path = Column(Text, nullable=True) # Best face shot
# Face embedding (512-dim for InsightFace buffalo_l)
embedding = Column(Vector(512), nullable=True)
# Stats
first_seen_at = Column(DateTime(timezone=True), nullable=True)
last_seen_at = Column(DateTime(timezone=True), nullable=True)
total_sightings = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
def __repr__(self):
return f"<PersonIdentity {self.name or 'Unknown'} ({self.total_sightings} sightings)>"
class FaceEvent(Base):
"""
Every face detection event — one row per detected face per frame.
"""
__tablename__ = "face_events"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
camera_id = Column(UUID(as_uuid=True), ForeignKey("cameras.id"), nullable=False, index=True)
identity_id = Column(UUID(as_uuid=True), ForeignKey("person_identities.id"), nullable=True, index=True)
# Detection
detection_confidence = Column(Float, nullable=True)
face_bbox = Column(JSON, nullable=True) # {"x","y","w","h"}
face_embedding = Column(Vector(512), nullable=True) # Per-event embedding for re-clustering
similarity_score = Column(Float, nullable=True) # Match score to identity
# Context — if detected alongside a vehicle event
vehicle_event_id = Column(UUID(as_uuid=True), ForeignKey("vehicle_events.id"), nullable=True)
# Storage
snapshot_path = Column(Text, nullable=True) # Full frame
face_snapshot_path = Column(Text, nullable=True) # Cropped face chip
# Meta
captured_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
def __repr__(self):
return f"<FaceEvent identity={self.identity_id} cam={self.camera_id} at={self.captured_at}>"

View File

@@ -0,0 +1,73 @@
from sqlalchemy import Column, String, Integer, Float, DateTime, Text, ForeignKey, Enum, JSON
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.sql import func
import uuid
import enum
from app.core.database import Base
class VehicleType(str, enum.Enum):
car = "car"
motorcycle = "motorcycle"
truck = "truck"
van = "van"
jeepney = "jeepney"
tricycle = "tricycle"
unknown = "unknown"
class VehicleIdentity(Base):
"""
Unique vehicle identity — grouped by license plate.
Tracks every time this vehicle was seen across any camera.
"""
__tablename__ = "vehicle_identities"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
plate_number = Column(String(20), nullable=True, index=True, unique=True)
vehicle_type = Column(Enum(VehicleType), default=VehicleType.unknown)
color = Column(String(50), nullable=True)
make_model = Column(String(100), nullable=True) # e.g. "Toyota Vios"
notes = Column(Text, nullable=True)
label = Column(String(100), nullable=True) # e.g. "Unit 4B Owner", "Delivery Van"
is_whitelisted = Column(Integer, default=0) # 1 = resident/approved
is_blacklisted = Column(Integer, default=0) # 1 = flagged/banned
thumbnail_path = Column(Text, nullable=True) # Best shot saved
first_seen_at = Column(DateTime(timezone=True), nullable=True)
last_seen_at = Column(DateTime(timezone=True), nullable=True)
total_sightings = Column(Integer, default=0)
created_at = Column(DateTime(timezone=True), server_default=func.now())
class VehicleEvent(Base):
"""
Every detection event — one row per camera capture.
"""
__tablename__ = "vehicle_events"
id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
camera_id = Column(UUID(as_uuid=True), ForeignKey("cameras.id"), nullable=False, index=True)
identity_id = Column(UUID(as_uuid=True), ForeignKey("vehicle_identities.id"), nullable=True, index=True)
# Detection data
plate_number = Column(String(20), nullable=True)
plate_confidence = Column(Float, nullable=True)
vehicle_type = Column(Enum(VehicleType), default=VehicleType.unknown)
color = Column(String(50), nullable=True)
person_count = Column(Integer, nullable=True) # For motorcycle: how many riders
detection_confidence = Column(Float, nullable=True)
# Bounding boxes (stored as JSON: {"x":0,"y":0,"w":100,"h":100})
vehicle_bbox = Column(JSON, nullable=True)
plate_bbox = Column(JSON, nullable=True)
# Storage
snapshot_path = Column(Text, nullable=True) # Full frame snapshot
plate_snapshot_path = Column(Text, nullable=True) # Cropped plate image
# Meta
captured_at = Column(DateTime(timezone=True), server_default=func.now(), index=True)
def __repr__(self):
return f"<VehicleEvent plate={self.plate_number} type={self.vehicle_type} at={self.captured_at}>"

View File

View File

@@ -0,0 +1,88 @@
"""
FaceService — InsightFace-based face detection, embedding, and identity matching.
Uses pgvector cosine similarity for fast 1:N identity search.
"""
import cv2
import numpy as np
import logging
from typing import Optional
from dataclasses import dataclass, field
from uuid import UUID
from app.core.config import settings
logger = logging.getLogger(__name__)
@dataclass
class FaceDetection:
bbox: dict = field(default_factory=dict) # {"x","y","w","h"}
embedding: Optional[np.ndarray] = None # 512-dim vector
detection_confidence: float = 0.0
face_crop: Optional[np.ndarray] = None
matched_identity_id: Optional[UUID] = None
similarity_score: float = 0.0
class FaceService:
def __init__(self):
self._app = None
self._ready = False
def load(self):
"""Load InsightFace model — call once at startup."""
try:
from insightface.app import FaceAnalysis
self._app = FaceAnalysis(
name=settings.FACE_MODEL,
allowed_modules=["detection", "recognition"],
)
self._app.prepare(
ctx_id=settings.GPU_DEVICE,
det_size=(640, 640),
)
self._ready = True
logger.info(f"InsightFace loaded (model={settings.FACE_MODEL})")
except Exception as e:
logger.error(f"Failed to load InsightFace: {e}")
def detect(self, frame: np.ndarray) -> list[FaceDetection]:
"""
Detect all faces in a frame and extract embeddings.
Returns list of FaceDetection objects.
"""
if not self._ready or self._app is None:
return []
try:
faces = self._app.get(frame)
except Exception as e:
logger.debug(f"Face detection error: {e}")
return []
detections = []
for face in faces:
bbox = face.bbox.astype(int)
x1, y1, x2, y2 = bbox[0], bbox[1], bbox[2], bbox[3]
det = FaceDetection(
bbox={"x": x1, "y": y1, "w": x2 - x1, "h": y2 - y1},
embedding=face.normed_embedding, # Already L2-normalized
detection_confidence=float(face.det_score),
face_crop=frame[max(0, y1):y2, max(0, x1):x2],
)
detections.append(det)
return detections
def cosine_similarity(self, emb1: np.ndarray, emb2: np.ndarray) -> float:
"""Cosine similarity between two normalized embeddings."""
return float(np.dot(emb1, emb2))
def embeddings_to_list(self, embedding: np.ndarray) -> list[float]:
"""Convert numpy embedding to list for pgvector storage."""
return embedding.tolist()
# Singleton
face_service = FaceService()

View File

@@ -0,0 +1,153 @@
"""
StreamManager — Manages RTSP stream connections and dispatches frames to workers.
Each camera runs in its own asyncio task with a frame queue.
"""
import asyncio
import cv2
import logging
from typing import Dict, Optional
from uuid import UUID
from datetime import datetime
from app.core.config import settings
logger = logging.getLogger(__name__)
class StreamWorker:
"""
Handles one RTSP camera stream.
Reads frames and puts them into a queue for the AI pipeline to consume.
"""
def __init__(self, camera_id: UUID, camera_name: str, rtsp_url: str):
self.camera_id = camera_id
self.camera_name = camera_name
self.rtsp_url = rtsp_url
self.frame_queue: asyncio.Queue = asyncio.Queue(maxsize=10)
self.is_running = False
self._task: Optional[asyncio.Task] = None
self.frame_count = 0
self.last_frame_at: Optional[datetime] = None
self.error: Optional[str] = None
async def start(self):
self.is_running = True
self._task = asyncio.create_task(self._read_loop())
logger.info(f"[{self.camera_name}] Stream started")
async def stop(self):
self.is_running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
logger.info(f"[{self.camera_name}] Stream stopped")
async def _read_loop(self):
"""Read frames from RTSP in a thread pool (OpenCV is blocking)."""
loop = asyncio.get_event_loop()
while self.is_running:
try:
cap = await loop.run_in_executor(
None,
lambda: cv2.VideoCapture(self.rtsp_url)
)
if not cap.isOpened():
self.error = "Cannot open RTSP stream"
logger.error(f"[{self.camera_name}] {self.error}")
await asyncio.sleep(5)
continue
self.error = None
frame_idx = 0
while self.is_running:
ret, frame = await loop.run_in_executor(None, cap.read)
if not ret:
logger.warning(f"[{self.camera_name}] Frame read failed, reconnecting...")
break
frame_idx += 1
self.frame_count += 1
self.last_frame_at = datetime.utcnow()
# Skip frames for performance
if frame_idx % settings.PROCESS_EVERY_N_FRAMES != 0:
continue
# Non-blocking put — drop frame if queue is full
try:
self.frame_queue.put_nowait({
"frame": frame,
"frame_idx": frame_idx,
"captured_at": self.last_frame_at,
})
except asyncio.QueueFull:
pass # AI pipeline is slow — drop frame
cap.release()
except asyncio.CancelledError:
raise
except Exception as e:
self.error = str(e)
logger.exception(f"[{self.camera_name}] Stream error: {e}")
await asyncio.sleep(5)
class StreamManager:
"""
Singleton that manages all active camera streams.
"""
_instance = None
def __init__(self):
self._workers: Dict[str, StreamWorker] = {}
@classmethod
def get(cls) -> "StreamManager":
if cls._instance is None:
cls._instance = StreamManager()
return cls._instance
async def start_stream(self, camera_id: UUID, camera_name: str, rtsp_url: str):
key = str(camera_id)
if key in self._workers:
await self.stop_stream(camera_id)
worker = StreamWorker(camera_id, camera_name, rtsp_url)
self._workers[key] = worker
await worker.start()
return worker
async def stop_stream(self, camera_id: UUID):
key = str(camera_id)
if key in self._workers:
await self._workers[key].stop()
del self._workers[key]
def get_worker(self, camera_id: UUID) -> Optional[StreamWorker]:
return self._workers.get(str(camera_id))
def get_all_status(self) -> list:
return [
{
"camera_id": str(k),
"is_running": w.is_running,
"frame_count": w.frame_count,
"last_frame_at": w.last_frame_at,
"error": w.error,
}
for k, w in self._workers.items()
]
async def stop_all(self):
for worker in list(self._workers.values()):
await worker.stop()
self._workers.clear()

View File

@@ -0,0 +1,227 @@
"""
VehicleDetector — YOLOv8-based vehicle detection, classification, and person counting.
Also handles license plate OCR via fast-alpr.
"""
import cv2
import numpy as np
import logging
from pathlib import Path
from typing import Optional
from dataclasses import dataclass, field
from app.core.config import settings
logger = logging.getLogger(__name__)
VEHICLE_CLASSES = {
2: "car",
3: "motorcycle",
5: "bus",
7: "truck",
}
# COCO person class
PERSON_CLASS = 0
# Color detection ranges (HSV)
COLOR_RANGES = {
"red": [(0, 70, 50), (10, 255, 255)],
"red2": [(170, 70, 50), (180, 255, 255)],
"blue": [(100, 70, 50), (130, 255, 255)],
"white": [(0, 0, 180), (180, 30, 255)],
"black": [(0, 0, 0), (180, 255, 50)],
"silver": [(0, 0, 100), (180, 30, 180)],
"yellow": [(20, 70, 50), (35, 255, 255)],
"green": [(35, 70, 50), (85, 255, 255)],
"orange": [(10, 70, 50), (20, 255, 255)],
}
@dataclass
class VehicleDetection:
vehicle_type: str = "unknown"
color: Optional[str] = None
plate_number: Optional[str] = None
plate_confidence: float = 0.0
person_count: int = 1
detection_confidence: float = 0.0
vehicle_bbox: dict = field(default_factory=dict)
plate_bbox: dict = field(default_factory=dict)
vehicle_crop: Optional[np.ndarray] = None
plate_crop: Optional[np.ndarray] = None
class VehicleDetector:
def __init__(self):
self._yolo = None
self._alpr = None
self._ready = False
def load(self):
"""Load models — call once at startup."""
try:
from ultralytics import YOLO
self._yolo = YOLO(settings.YOLO_MODEL)
logger.info("YOLO model loaded")
except Exception as e:
logger.error(f"Failed to load YOLO: {e}")
return
try:
from fast_alpr import ALPR
self._alpr = ALPR(
detector_model="plate-detection-v1-large",
ocr_model="global-plates-mobile-vit-v2-model",
device="cuda" if settings.GPU_DEVICE >= 0 else "cpu",
)
logger.info("ALPR model loaded")
except Exception as e:
logger.warning(f"ALPR not loaded (will skip plate reading): {e}")
self._ready = True
def detect(self, frame: np.ndarray) -> list[VehicleDetection]:
"""
Run vehicle detection on a frame.
Returns list of VehicleDetection objects.
"""
if not self._ready or self._yolo is None:
return []
results = self._yolo(
frame,
conf=settings.DETECTION_CONFIDENCE_THRESHOLD,
verbose=False,
)
detections = []
h, w = frame.shape[:2]
for result in results:
boxes = result.boxes
if boxes is None:
continue
# Group: find all vehicles and all persons
vehicles = []
persons = []
for box in boxes:
cls_id = int(box.cls[0])
conf = float(box.conf[0])
x1, y1, x2, y2 = map(int, box.xyxy[0])
if cls_id in VEHICLE_CLASSES:
vehicles.append({
"type": VEHICLE_CLASSES[cls_id],
"conf": conf,
"bbox": {"x": x1, "y": y1, "w": x2 - x1, "h": y2 - y1},
"crop": frame[y1:y2, x1:x2],
})
elif cls_id == PERSON_CLASS:
persons.append({"bbox": {"x": x1, "y": y1, "w": x2 - x1, "h": y2 - y1}})
for vehicle in vehicles:
det = VehicleDetection(
vehicle_type=vehicle["type"],
detection_confidence=vehicle["conf"],
vehicle_bbox=vehicle["bbox"],
vehicle_crop=vehicle["crop"],
)
# Detect color
det.color = self._detect_color(vehicle["crop"])
# Count persons on/near motorcycle
if vehicle["type"] == "motorcycle":
det.person_count = self._count_persons_on_motorcycle(
vehicle["bbox"], persons
)
# Read plate
if self._alpr is not None:
plate_result = self._read_plate(frame, vehicle["bbox"])
if plate_result:
det.plate_number = plate_result["text"]
det.plate_confidence = plate_result["confidence"]
det.plate_bbox = plate_result["bbox"]
bx = plate_result["bbox"]
det.plate_crop = frame[
bx["y"]:bx["y"] + bx["h"],
bx["x"]:bx["x"] + bx["w"]
]
detections.append(det)
return detections
def _read_plate(self, frame: np.ndarray, vehicle_bbox: dict) -> Optional[dict]:
"""Run ALPR on vehicle region."""
if self._alpr is None:
return None
try:
# Expand vehicle crop slightly for plate detection
x, y, w, h = vehicle_bbox["x"], vehicle_bbox["y"], vehicle_bbox["w"], vehicle_bbox["h"]
crop = frame[max(0, y):y + h, max(0, x):x + w]
results = self._alpr.run(crop)
if results:
best = results[0]
ocr = best.ocr
if ocr and ocr.text:
# Offset bbox back to full frame coordinates
pb = best.detection.bounding_box
return {
"text": ocr.text.upper().replace(" ", ""),
"confidence": float(ocr.confidence),
"bbox": {
"x": x + int(pb.x1),
"y": y + int(pb.y1),
"w": int(pb.x2 - pb.x1),
"h": int(pb.y2 - pb.y1),
}
}
except Exception as e:
logger.debug(f"ALPR error: {e}")
return None
def _detect_color(self, crop: np.ndarray) -> str:
"""Detect dominant vehicle color using HSV histogram."""
if crop is None or crop.size == 0:
return "unknown"
try:
hsv = cv2.cvtColor(crop, cv2.COLOR_BGR2HSV)
max_pixels = 0
detected_color = "unknown"
for color_name, (lower, upper) in COLOR_RANGES.items():
mask = cv2.inRange(hsv, np.array(lower), np.array(upper))
pixel_count = cv2.countNonZero(mask)
if pixel_count > max_pixels:
max_pixels = pixel_count
detected_color = color_name.replace("2", "") # red2 → red
return detected_color
except Exception:
return "unknown"
def _count_persons_on_motorcycle(self, moto_bbox: dict, persons: list) -> int:
"""Count persons whose center falls within or near the motorcycle bbox."""
count = 0
mx, my, mw, mh = moto_bbox["x"], moto_bbox["y"], moto_bbox["w"], moto_bbox["h"]
for person in persons:
px, py, pw, ph = person["bbox"]["x"], person["bbox"]["y"], person["bbox"]["w"], person["bbox"]["h"]
# Person center
cx = px + pw // 2
cy = py + ph // 2
# Check if center is within motorcycle bounding box (with some margin)
margin = 30
if (mx - margin <= cx <= mx + mw + margin and
my - margin <= cy <= my + mh + margin):
count += 1
return max(count, 1) # At least 1 rider assumed
# Singleton
vehicle_detector = VehicleDetector()

View File

View File

@@ -0,0 +1,250 @@
"""
Detection Pipeline Worker
Pulls frames from a StreamWorker queue and runs:
1. Vehicle detection (type, color, plate, person count)
2. Face detection (embedding, identity match)
3. Saves snapshots and events to DB
"""
import asyncio
import cv2
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
from uuid import UUID
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select, text
from app.core.config import settings
from app.core.database import AsyncSessionLocal
from app.models.camera import Camera
from app.models.vehicle import VehicleEvent, VehicleIdentity, VehicleType
from app.models.person import FaceEvent, PersonIdentity
from app.services.vehicle_detector import vehicle_detector
from app.services.face_service import face_service
from app.services.stream_manager import StreamWorker
logger = logging.getLogger(__name__)
class DetectionPipeline:
"""
Runs detection on frames for a single camera stream.
"""
def __init__(self, camera_id: UUID, camera_name: str, worker: StreamWorker):
self.camera_id = camera_id
self.camera_name = camera_name
self.worker = worker
self.snapshot_dir = Path(settings.STORAGE_PATH) / str(camera_id)
self.snapshot_dir.mkdir(parents=True, exist_ok=True)
self._running = False
self._task = None
async def start(self):
self._running = True
self._task = asyncio.create_task(self._loop())
logger.info(f"[{self.camera_name}] Pipeline started")
async def stop(self):
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
async def _loop(self):
loop = asyncio.get_event_loop()
while self._running:
try:
frame_data = await asyncio.wait_for(
self.worker.frame_queue.get(), timeout=2.0
)
except asyncio.TimeoutError:
continue
except asyncio.CancelledError:
raise
frame = frame_data["frame"]
captured_at = frame_data["captured_at"]
try:
await loop.run_in_executor(
None, self._process_frame, frame, captured_at
)
except Exception as e:
logger.exception(f"[{self.camera_name}] Pipeline error: {e}")
def _process_frame(self, frame, captured_at: datetime):
"""Synchronous processing — runs in thread pool."""
import asyncio
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
try:
loop.run_until_complete(self._async_process(frame, captured_at))
finally:
loop.close()
async def _async_process(self, frame, captured_at: datetime):
"""Run detections and save to DB."""
# --- Vehicle Detection ---
vehicle_detections = vehicle_detector.detect(frame)
# --- Face Detection ---
face_detections = face_service.detect(frame)
if not vehicle_detections and not face_detections:
return
# Save snapshot
ts = captured_at.strftime("%Y%m%d_%H%M%S_%f")
snapshot_filename = f"{ts}.jpg"
snapshot_path = str(self.snapshot_dir / snapshot_filename)
cv2.imwrite(snapshot_path, frame)
async with AsyncSessionLocal() as db:
vehicle_event_ids = []
# Save vehicle events
for det in vehicle_detections:
identity_id = await self._get_or_create_vehicle_identity(
db, det.plate_number, det.vehicle_type, det.color
)
# Save plate crop
plate_snap = None
if det.plate_crop is not None and det.plate_crop.size > 0:
plate_snap = str(self.snapshot_dir / f"{ts}_plate.jpg")
cv2.imwrite(plate_snap, det.plate_crop)
event = VehicleEvent(
camera_id=self.camera_id,
identity_id=identity_id,
plate_number=det.plate_number,
plate_confidence=det.plate_confidence,
vehicle_type=det.vehicle_type,
color=det.color,
person_count=det.person_count,
detection_confidence=det.detection_confidence,
vehicle_bbox=det.vehicle_bbox,
plate_bbox=det.plate_bbox,
snapshot_path=snapshot_path,
plate_snapshot_path=plate_snap,
captured_at=captured_at,
)
db.add(event)
await db.flush()
vehicle_event_ids.append(event.id)
# Save face events
for i, det in enumerate(face_detections):
identity_id, similarity = await self._match_or_create_person(
db, det.embedding
)
# Save face crop
face_snap = None
if det.face_crop is not None and det.face_crop.size > 0:
face_snap = str(self.snapshot_dir / f"{ts}_face{i}.jpg")
cv2.imwrite(face_snap, det.face_crop)
embedding_list = face_service.embeddings_to_list(det.embedding) if det.embedding is not None else None
event = FaceEvent(
camera_id=self.camera_id,
identity_id=identity_id,
detection_confidence=det.detection_confidence,
face_bbox=det.bbox,
face_embedding=embedding_list,
similarity_score=similarity,
vehicle_event_id=vehicle_event_ids[0] if vehicle_event_ids else None,
snapshot_path=snapshot_path,
face_snapshot_path=face_snap,
captured_at=captured_at,
)
db.add(event)
await db.commit()
async def _get_or_create_vehicle_identity(
self, db: AsyncSession, plate_number, vehicle_type, color
):
"""Find existing vehicle identity by plate or create new one."""
if not plate_number:
return None
result = await db.execute(
select(VehicleIdentity).where(VehicleIdentity.plate_number == plate_number)
)
identity = result.scalar_one_or_none()
if identity:
identity.last_seen_at = datetime.now(timezone.utc)
identity.total_sightings += 1
if color and not identity.color:
identity.color = color
else:
identity = VehicleIdentity(
plate_number=plate_number,
vehicle_type=vehicle_type or VehicleType.unknown,
color=color,
first_seen_at=datetime.now(timezone.utc),
last_seen_at=datetime.now(timezone.utc),
total_sightings=1,
)
db.add(identity)
await db.flush()
return identity.id
async def _match_or_create_person(self, db: AsyncSession, embedding):
"""
Find closest matching person identity using pgvector cosine similarity.
If no match above threshold, create new identity.
Returns (identity_id, similarity_score).
"""
if embedding is None:
return None, 0.0
emb_list = face_service.embeddings_to_list(embedding)
emb_str = f"[{','.join(str(x) for x in emb_list)}]"
# pgvector cosine distance (1 - cosine_similarity)
threshold = settings.FACE_SIMILARITY_THRESHOLD # distance threshold
result = await db.execute(
text(f"""
SELECT id, 1 - (embedding <=> '{emb_str}'::vector) AS similarity
FROM person_identities
WHERE embedding IS NOT NULL
AND (embedding <=> '{emb_str}'::vector) < :threshold
ORDER BY embedding <=> '{emb_str}'::vector
LIMIT 1
"""),
{"threshold": threshold}
)
row = result.fetchone()
if row:
identity_id, similarity = row
# Update last seen
await db.execute(
text("UPDATE person_identities SET last_seen_at=NOW(), total_sightings=total_sightings+1 WHERE id=:id"),
{"id": identity_id}
)
return identity_id, float(similarity)
else:
# New unknown person
identity = PersonIdentity(
first_seen_at=datetime.now(timezone.utc),
last_seen_at=datetime.now(timezone.utc),
total_sightings=1,
embedding=emb_list,
)
db.add(identity)
await db.flush()
return identity.id, 0.0