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

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