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:
0
backend/app/api/__init__.py
Normal file
0
backend/app/api/__init__.py
Normal file
118
backend/app/api/cameras.py
Normal file
118
backend/app/api/cameras.py
Normal 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
130
backend/app/api/persons.py
Normal 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
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