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

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}>"