Files
bantaycam/backend/app/api/persons.py
Nemo 7c43e4580d 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
2026-03-12 12:11:02 +08:00

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
]