Prometheus-prototype / api /routes /detection.py
Tadiwa-M
Deploy: auto-derive dedup radius (drop hardcoded 3m bypass)
58aefd4
Raw
History Blame Contribute Delete
4.85 kB
"""Detection endpoints."""
from __future__ import annotations
import io
import shutil
import tempfile
from pathlib import Path
from fastapi import APIRouter, File, Form, HTTPException, UploadFile
from fastapi.responses import FileResponse
from api.schemas import DetectionResponse, VideoJobCreated, VideoJobStatus
from api.services import detection_service
router = APIRouter(prefix="/api/detect", tags=["detection"])
@router.post("/image", response_model=DetectionResponse)
async def detect_image(
file: UploadFile = File(...),
model: str | None = Form(default=None),
conf: float = Form(default=0.25),
heatmap: bool = Form(default=True),
) -> DetectionResponse:
# Imported here (not at module top) so the API boots without the detection
# stack — only this endpoint actually requires numpy/PIL/torch.
try:
import numpy as np
from PIL import Image
except ImportError as exc:
raise HTTPException(
status_code=503,
detail=(
"Detection stack not installed on the server "
"(pip install -r requirements.txt). Catalog and metrics "
"endpoints remain available."
),
) from exc
if not file.content_type or not file.content_type.startswith("image/"):
raise HTTPException(status_code=400, detail="Expected an image upload.")
raw = await file.read()
try:
img_rgb = np.array(Image.open(io.BytesIO(raw)).convert("RGB"))
except Exception as exc: # noqa: BLE001 — surface a clean 400 to the client
raise HTTPException(status_code=400, detail=f"Unreadable image: {exc}") from exc
conf = float(min(max(conf, 0.01), 0.99))
try:
result = detection_service.detect_image(img_rgb, model, conf, want_heatmap=heatmap)
except FileNotFoundError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return DetectionResponse(**result)
@router.post("/video", response_model=VideoJobCreated)
async def detect_video(
file: UploadFile = File(...),
model: str | None = Form(default=None),
conf: float = Form(default=0.25),
stride: int = Form(default=3),
# Optional survey geometry — if altitude+hfov are given, the job also
# produces a distance-sampling population estimate (per-track distances).
altitude: float | None = Form(default=None),
hfov: float | None = Form(default=None),
pitch: float = Form(default=0.0),
heading: float = Form(default=0.0),
frame_spacing_m: float = Form(default=30.0),
) -> VideoJobCreated:
try:
from api.services import video_service
import cv2 # noqa: F401 — fail fast if the detection stack is missing
except ImportError as exc:
raise HTTPException(
status_code=503,
detail="Detection stack not installed on the server "
"(pip install -r requirements.txt).",
) from exc
if not file.content_type or not file.content_type.startswith("video/"):
raise HTTPException(status_code=400, detail="Expected a video upload.")
conf = float(min(max(conf, 0.01), 0.99))
stride = int(min(max(stride, 1), 30))
survey = None
if altitude is not None and hfov is not None:
survey = {"altitude": altitude, "hfov": hfov, "pitch": pitch,
"heading": heading, "frame_spacing_m": frame_spacing_m}
# Stream the upload to a temp file — survey clips can be large.
suffix = Path(file.filename or "clip.mp4").suffix or ".mp4"
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
shutil.copyfileobj(file.file, tmp)
tmp_path = tmp.name
job_id = video_service.create_job(
tmp_path, file.filename or "clip.mp4", model, conf, stride, survey=survey
)
return VideoJobCreated(job_id=job_id)
@router.get("/video/{job_id}", response_model=VideoJobStatus)
def video_status(job_id: str) -> VideoJobStatus:
from api.services import video_service
job = video_service.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Unknown video job.")
return VideoJobStatus(**{k: v for k, v in job.items() if not k.startswith("_")})
@router.get("/video/{job_id}/file")
def video_file(job_id: str) -> FileResponse:
from api.services import video_service
job = video_service.get_job(job_id)
if job is None:
raise HTTPException(status_code=404, detail="Unknown video job.")
out = job.get("_output_path")
if job["status"] != "done" or not out or not Path(out).exists():
raise HTTPException(status_code=409, detail="Video not ready.")
stem = Path(job.get("filename") or "clip").stem
return FileResponse(out, media_type="video/mp4",
filename=f"prometheus_{stem}_annotated.mp4")