- 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
131 lines
4.1 KiB
Python
131 lines
4.1 KiB
Python
"""
|
|
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
|
|
]
|