- 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
140 lines
4.3 KiB
Python
140 lines
4.3 KiB
Python
"""
|
|
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"}
|