"""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")