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:
139
backend/app/api/vehicles.py
Normal file
139
backend/app/api/vehicles.py
Normal 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"}
|
||||
Reference in New Issue
Block a user