Skip to content

Commit be0fe58

Browse files
committed
feat: add FastAPI backend for city CCTV surveillance
- Camera CRUD with go2rtc RTSP stream registration - Incident model with type/severity classification (traffic_accident, fight, etc.) - VLM analysis endpoint: snapshot capture → Celery job → VLM → incident record - LLM agent query endpoint with DB context injection - Shift/daily report generator via LLM agent - WebSocket real-time alert stream via Redis pub/sub - MinIO snapshot storage service - PostgreSQL + asyncpg async ORM (SQLAlchemy 2.0) - Celery worker for async VLM jobs - Docker Compose stack: backend, worker, db, redis, minio, go2rtc Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DXvYppU6bAHzS9R8HbTHdN
1 parent 2264fcb commit be0fe58

25 files changed

Lines changed: 1087 additions & 0 deletions

docker/docker-compose-backend.yml

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
version: '3.8'
2+
3+
services:
4+
db:
5+
image: postgres:16-alpine
6+
environment:
7+
POSTGRES_DB: namucam
8+
POSTGRES_USER: namucam
9+
POSTGRES_PASSWORD: namucam
10+
volumes:
11+
- pg_data:/var/lib/postgresql/data
12+
healthcheck:
13+
test: ["CMD-SHELL", "pg_isready -U namucam"]
14+
interval: 5s
15+
timeout: 5s
16+
retries: 5
17+
18+
redis:
19+
image: redis:7-alpine
20+
volumes:
21+
- redis_data:/data
22+
23+
minio:
24+
image: minio/minio
25+
command: server /data --console-address ":9001"
26+
environment:
27+
MINIO_ROOT_USER: minioadmin
28+
MINIO_ROOT_PASSWORD: minioadmin
29+
volumes:
30+
- minio_data:/data
31+
ports:
32+
- "9000:9000"
33+
- "9001:9001"
34+
35+
go2rtc:
36+
image: alexxit/go2rtc
37+
ports:
38+
- "1984:1984"
39+
volumes:
40+
- ./go2rtc.yaml:/config/go2rtc.yaml
41+
42+
backend:
43+
build:
44+
context: ../src/backend
45+
dockerfile: Dockerfile
46+
ports:
47+
- "8000:8000"
48+
environment:
49+
DATABASE_URL: postgresql+asyncpg://namucam:namucam@db:5432/namucam
50+
REDIS_URL: redis://redis:6379/0
51+
MINIO_ENDPOINT: minio:9000
52+
MINIO_ACCESS_KEY: minioadmin
53+
MINIO_SECRET_KEY: minioadmin
54+
GO2RTC_URL: http://go2rtc:1984
55+
VLM_BASE_URL: ${VLM_BASE_URL:-http://host.docker.internal:5405}
56+
LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:5407}
57+
depends_on:
58+
db:
59+
condition: service_healthy
60+
redis:
61+
condition: service_started
62+
volumes:
63+
- snapshots:/tmp/snapshots
64+
65+
worker:
66+
build:
67+
context: ../src/backend
68+
dockerfile: Dockerfile
69+
command: celery -A backend.workers.celery_app worker --loglevel=info --concurrency=4
70+
environment:
71+
DATABASE_URL: postgresql+asyncpg://namucam:namucam@db:5432/namucam
72+
REDIS_URL: redis://redis:6379/0
73+
MINIO_ENDPOINT: minio:9000
74+
MINIO_ACCESS_KEY: minioadmin
75+
MINIO_SECRET_KEY: minioadmin
76+
GO2RTC_URL: http://go2rtc:1984
77+
VLM_BASE_URL: ${VLM_BASE_URL:-http://host.docker.internal:5405}
78+
LLM_BASE_URL: ${LLM_BASE_URL:-http://host.docker.internal:5407}
79+
depends_on:
80+
- db
81+
- redis
82+
volumes:
83+
- snapshots:/tmp/snapshots
84+
85+
volumes:
86+
pg_data:
87+
redis_data:
88+
minio_data:
89+
snapshots:

docker/go2rtc.yaml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
api:
2+
listen: ":1984"
3+
4+
# Streams are added dynamically via the go2rtc REST API
5+
# Example:
6+
# streams:
7+
# cam_001: rtsp://192.168.1.100/stream1
8+
# cam_002: rtsp://192.168.1.101/stream1
9+
streams: {}
10+
11+
webrtc:
12+
listen: ":8555"

src/backend/Dockerfile

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
FROM python:3.11-slim
2+
3+
WORKDIR /app
4+
5+
RUN apt-get update && apt-get install -y --no-install-recommends \
6+
libpq-dev gcc && rm -rf /var/lib/apt/lists/*
7+
8+
COPY requirements.txt .
9+
RUN pip install --no-cache-dir -r requirements.txt
10+
11+
COPY . /app/backend
12+
13+
ENV PYTHONPATH=/app
14+
15+
# API server
16+
CMD ["uvicorn", "backend.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

src/backend/__init__.py

Whitespace-only changes.

src/backend/api/__init__.py

Whitespace-only changes.

src/backend/api/agent.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
from fastapi import APIRouter, Depends, HTTPException
2+
from sqlalchemy.ext.asyncio import AsyncSession
3+
from sqlalchemy import select, func
4+
from pydantic import BaseModel
5+
from typing import Optional
6+
from datetime import datetime, timedelta, timezone
7+
8+
from backend.db.database import get_db
9+
from backend.models.incident import Incident
10+
from backend.models.camera import Camera
11+
from backend.services.agent_service import query_agent
12+
13+
router = APIRouter(prefix="/agent", tags=["agent"])
14+
15+
16+
class QueryRequest(BaseModel):
17+
question: str
18+
# Optional time window — defaults to last 24 hours
19+
from_time: Optional[datetime] = None
20+
to_time: Optional[datetime] = None
21+
22+
23+
class QueryResponse(BaseModel):
24+
question: str
25+
answer: str
26+
incidents_in_context: int
27+
28+
29+
@router.post("/query", response_model=QueryResponse)
30+
async def query(payload: QueryRequest, db: AsyncSession = Depends(get_db)):
31+
"""Natural language query over incident data."""
32+
now = datetime.now(timezone.utc)
33+
from_time = payload.from_time or (now - timedelta(hours=24))
34+
to_time = payload.to_time or now
35+
36+
# Build context from DB
37+
incidents_result = await db.execute(
38+
select(Incident)
39+
.where(Incident.occurred_at >= from_time, Incident.occurred_at <= to_time)
40+
.order_by(Incident.occurred_at.desc())
41+
.limit(100)
42+
)
43+
incidents = incidents_result.scalars().all()
44+
45+
cameras_result = await db.execute(select(Camera))
46+
cameras = {str(c.id): c.name for c in cameras_result.scalars().all()}
47+
48+
context = {
49+
"query_window": {"from": from_time.isoformat(), "to": to_time.isoformat()},
50+
"total_incidents": len(incidents),
51+
"incidents": [
52+
{
53+
"id": str(inc.id),
54+
"camera": cameras.get(str(inc.camera_id), str(inc.camera_id)),
55+
"type": inc.incident_type,
56+
"severity": inc.severity,
57+
"description": inc.description,
58+
"occurred_at": inc.occurred_at.isoformat(),
59+
"resolved": inc.resolved,
60+
"confidence": inc.confidence,
61+
}
62+
for inc in incidents
63+
],
64+
}
65+
66+
answer = await query_agent(payload.question, context)
67+
68+
return QueryResponse(
69+
question=payload.question,
70+
answer=answer,
71+
incidents_in_context=len(incidents),
72+
)
73+
74+
75+
@router.post("/report")
76+
async def generate_report(
77+
from_time: Optional[datetime] = None,
78+
to_time: Optional[datetime] = None,
79+
district: Optional[str] = None,
80+
db: AsyncSession = Depends(get_db),
81+
):
82+
"""Generate a structured shift/daily report using the LLM agent."""
83+
now = datetime.now(timezone.utc)
84+
from_time = from_time or (now - timedelta(hours=8))
85+
to_time = to_time or now
86+
87+
q = select(Incident).where(
88+
Incident.occurred_at >= from_time,
89+
Incident.occurred_at <= to_time,
90+
)
91+
result = await db.execute(q)
92+
incidents = result.scalars().all()
93+
94+
cam_q = select(Camera)
95+
if district:
96+
cam_q = cam_q.where(Camera.district == district)
97+
cam_result = await db.execute(cam_q)
98+
cameras = {str(c.id): {"name": c.name, "location": c.location} for c in cam_result.scalars().all()}
99+
100+
context = {
101+
"report_period": {"from": from_time.isoformat(), "to": to_time.isoformat()},
102+
"district": district or "all",
103+
"cameras": cameras,
104+
"incidents": [
105+
{
106+
"camera": cameras.get(str(inc.camera_id), {}).get("name", "unknown"),
107+
"type": inc.incident_type,
108+
"severity": inc.severity,
109+
"description": inc.description,
110+
"occurred_at": inc.occurred_at.isoformat(),
111+
"resolved": inc.resolved,
112+
}
113+
for inc in incidents
114+
if str(inc.camera_id) in cameras
115+
],
116+
}
117+
118+
prompt = (
119+
"Generate a formal shift surveillance report based on the provided incident data. "
120+
"Include: executive summary, incident breakdown by type and severity, "
121+
"notable events, unresolved incidents requiring attention, and recommendations."
122+
)
123+
124+
report_text = await query_agent(prompt, context)
125+
126+
return {
127+
"period": {"from": from_time.isoformat(), "to": to_time.isoformat()},
128+
"district": district or "all",
129+
"total_incidents": len(context["incidents"]),
130+
"report": report_text,
131+
}

src/backend/api/analyze.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks
2+
from sqlalchemy.ext.asyncio import AsyncSession
3+
from pydantic import BaseModel
4+
from typing import Optional
5+
from uuid import UUID
6+
import httpx
7+
import tempfile
8+
import os
9+
10+
from backend.db.database import get_db
11+
from backend.models.camera import Camera
12+
from backend.models.incident import AnalysisJob
13+
from backend.workers.tasks import analyze_frame_task
14+
from backend.config import settings
15+
16+
router = APIRouter(prefix="/analyze", tags=["analyze"])
17+
18+
19+
class AnalyzeRequest(BaseModel):
20+
camera_id: UUID
21+
custom_prompt: Optional[str] = None
22+
23+
24+
class AnalyzeResponse(BaseModel):
25+
job_id: str
26+
status: str
27+
message: str
28+
29+
30+
class JobStatusResponse(BaseModel):
31+
job_id: str
32+
status: str
33+
result: Optional[dict] = None
34+
error: Optional[str] = None
35+
36+
class Config:
37+
from_attributes = True
38+
39+
40+
@router.post("/trigger", response_model=AnalyzeResponse)
41+
async def trigger_analysis(payload: AnalyzeRequest, db: AsyncSession = Depends(get_db)):
42+
"""Grab a snapshot from the camera and queue a VLM analysis job."""
43+
cam = await db.get(Camera, payload.camera_id)
44+
if not cam:
45+
raise HTTPException(status_code=404, detail="Camera not found")
46+
if not cam.is_active:
47+
raise HTTPException(status_code=400, detail="Camera is inactive")
48+
49+
# Capture snapshot from go2rtc
50+
snap_path = await _capture_snapshot(cam.name)
51+
52+
# Create job record
53+
job = AnalysisJob(camera_id=payload.camera_id, frame_path=snap_path, status="pending")
54+
db.add(job)
55+
await db.flush()
56+
57+
# Queue Celery task
58+
task = analyze_frame_task.delay(
59+
str(payload.camera_id),
60+
snap_path,
61+
str(job.id),
62+
)
63+
job.celery_task_id = task.id
64+
65+
return AnalyzeResponse(
66+
job_id=str(job.id),
67+
status="queued",
68+
message=f"Analysis queued for camera '{cam.name}'",
69+
)
70+
71+
72+
@router.get("/jobs/{job_id}", response_model=JobStatusResponse)
73+
async def get_job_status(job_id: UUID, db: AsyncSession = Depends(get_db)):
74+
job = await db.get(AnalysisJob, job_id)
75+
if not job:
76+
raise HTTPException(status_code=404, detail="Job not found")
77+
return JobStatusResponse(
78+
job_id=str(job.id),
79+
status=job.status,
80+
result=job.result,
81+
error=job.error,
82+
)
83+
84+
85+
async def _capture_snapshot(camera_name: str) -> str:
86+
"""Download a JPEG snapshot from go2rtc and save to a temp file."""
87+
name = camera_name.replace(" ", "_")
88+
url = f"{settings.GO2RTC_URL}/snapshot?src={name}"
89+
tmp = tempfile.NamedTemporaryFile(suffix=".jpg", delete=False)
90+
try:
91+
async with httpx.AsyncClient(timeout=10.0) as client:
92+
resp = await client.get(url)
93+
resp.raise_for_status()
94+
tmp.write(resp.content)
95+
tmp.flush()
96+
return tmp.name
97+
except Exception as exc:
98+
tmp.close()
99+
os.unlink(tmp.name)
100+
raise HTTPException(status_code=502, detail=f"Failed to capture snapshot: {exc}")

0 commit comments

Comments
 (0)