from __future__ import annotations from uuid import UUID from fastapi import APIRouter, Depends, Query, Request from sqlalchemy.orm import Session from app.api.guest_scope import ( assert_guest_project_scope, guest_project_scope, guest_scoped_project_filter, ) from app.db.session import get_db from app.schemas import ( AnalysisQaResponse, DetectionListResponse, DetectionModelsResponse, DetectionComparisonRequest, DetectionComparisonResponse, DetectionQaRequest, DetectionRead, DetectionRunListResponse, DetectionRunRead, DetectionRunRequest, DetectionRunResponse, Envelope, GeoJsonFeatureCollection, JobRead, ModelAssetListResponse, YoloPreflightResponse, ) from app.services.detection_comparison_service import DetectionComparisonService from app.services.dataset_service import DatasetService from app.services.detection_service import DetectionService from app.services.model_asset_catalog_service import ModelAssetCatalogService from app.services.model_registry_service import ModelRegistryService from app.services.yolo_preflight_service import YoloPreflightService from app.utils.response import envelope router = APIRouter(prefix="/detection", tags=["detection"]) @router.get("/models", response_model=Envelope[DetectionModelsResponse]) def list_detection_models() -> dict: return envelope({"models": [model.model_dump() for model in ModelRegistryService.list_model_capabilities()]}) @router.get("/model-assets", response_model=Envelope[ModelAssetListResponse]) def list_detection_model_assets() -> dict: return envelope(ModelAssetCatalogService.list_assets().model_dump()) @router.get("/yolo/preflight", response_model=Envelope[YoloPreflightResponse]) def get_yolo_preflight( tile_manifest_path: str | None = None, check_model_load: bool = False, model_asset_id: str | None = None, db: Session = Depends(get_db), ) -> dict: return envelope( YoloPreflightService.run( tile_manifest_path=tile_manifest_path, check_model_load=check_model_load, model_asset_id=model_asset_id, db=db, ) ) @router.post("/run", response_model=Envelope[DetectionRunResponse]) def run_detection( payload: DetectionRunRequest, request: Request, db: Session = Depends(get_db), ) -> dict: assert_guest_project_scope(request, payload.project_id) result = DetectionService.run_detection( db=db, project_id=payload.project_id, dataset_id=payload.dataset_id, model_id=payload.model_id, model_asset_id=payload.model_asset_id, confidence_threshold=payload.confidence_threshold, class_filter=payload.class_filter, tile_manifest_path=payload.tile_manifest_path, parameters_json=payload.parameters_json, ) return envelope(result.model_dump()) @router.post("/run-async", response_model=Envelope[JobRead]) def queue_detection( payload: DetectionRunRequest, request: Request, db: Session = Depends(get_db), ) -> dict: """Queue a detection run for the background worker. Tiled GPU inference takes minutes; ``POST /detection/run`` performs it inside the request and is only appropriate for a handful of tiles. Poll ``GET /jobs/{id}`` for the queued run instead. """ assert_guest_project_scope(request, payload.project_id) job = DetectionService.enqueue_detection( db=db, project_id=payload.project_id, dataset_id=payload.dataset_id, model_id=payload.model_id, model_asset_id=payload.model_asset_id, confidence_threshold=payload.confidence_threshold, class_filter=payload.class_filter, tile_manifest_path=payload.tile_manifest_path, parameters_json=payload.parameters_json, ) return envelope(JobRead.model_validate(job).model_dump(mode="json")) @router.get("/runs", response_model=Envelope[DetectionRunListResponse]) def list_detection_runs( request: Request, project_id: UUID | None = None, dataset_id: UUID | None = None, limit: int = Query(default=DetectionService.DEFAULT_RUN_LIST_LIMIT, ge=0, le=5_000), offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: project_id = guest_scoped_project_filter(request, project_id) return envelope( DetectionService.list_runs( db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset ).model_dump() ) @router.get("/runs/{analysis_run_id}", response_model=Envelope[DetectionRunRead]) def get_detection_run( analysis_run_id: UUID, request: Request, db: Session = Depends(get_db), ) -> dict: run = DetectionService.get_run(db, analysis_run_id) assert_guest_project_scope(request, run.project_id) return envelope(run.model_dump()) @router.get( "/runs/{analysis_run_id}/detections", response_model=Envelope[DetectionListResponse], ) def list_detection_run_detections( analysis_run_id: UUID, request: Request, dataset_id: UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, limit: int = Query( default=DetectionService.DEFAULT_RESULT_LIMIT, ge=0, le=50_000, description="Maximum results to return; 0 returns everything. Highest confidence first.", ), offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: if guest_project_scope(request) is not None: run = DetectionService.get_run(db, analysis_run_id) assert_guest_project_scope(request, run.project_id) return envelope( DetectionService.list_detections( db, analysis_run_id=analysis_run_id, dataset_id=dataset_id, class_name=class_name, min_confidence=min_confidence, limit=limit, offset=offset, ).model_dump() ) @router.get( "/datasets/{dataset_id}/detections", response_model=Envelope[DetectionListResponse], ) def list_dataset_detections( dataset_id: UUID, request: Request, analysis_run_id: UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, limit: int = Query( default=DetectionService.DEFAULT_RESULT_LIMIT, ge=0, le=50_000, description="Maximum results to return; 0 returns everything. Highest confidence first.", ), offset: int = Query(default=0, ge=0), db: Session = Depends(get_db), ) -> dict: if guest_project_scope(request) is not None: dataset = DatasetService.get_dataset(db, dataset_id) assert_guest_project_scope(request, dataset.project_id) return envelope( DetectionService.list_detections( db, analysis_run_id=analysis_run_id, dataset_id=dataset_id, class_name=class_name, min_confidence=min_confidence, limit=limit, offset=offset, ).model_dump() ) @router.get("/detections/{detection_id}", response_model=Envelope[DetectionRead]) def get_detection( detection_id: UUID, request: Request, db: Session = Depends(get_db), ) -> dict: detection = DetectionService.get_detection(db, detection_id) assert_guest_project_scope(request, detection.project_id) return envelope(detection.model_dump()) @router.get( "/runs/{analysis_run_id}/geojson", response_model=Envelope[GeoJsonFeatureCollection], ) def get_detection_run_geojson( analysis_run_id: UUID, request: Request, class_name: str | None = None, min_confidence: float | None = None, limit: int = Query( default=DetectionService.DEFAULT_RESULT_LIMIT, ge=0, le=50_000, description="Maximum results to return; 0 returns everything. Highest confidence first.", ), db: Session = Depends(get_db), ) -> dict: if guest_project_scope(request) is not None: run = DetectionService.get_run(db, analysis_run_id) assert_guest_project_scope(request, run.project_id) return envelope( DetectionService.detections_to_geojson( db, limit=limit, analysis_run_id=analysis_run_id, class_name=class_name, min_confidence=min_confidence, ) ) @router.get( "/datasets/{dataset_id}/geojson", response_model=Envelope[GeoJsonFeatureCollection], ) def get_dataset_detection_geojson( dataset_id: UUID, request: Request, analysis_run_id: UUID | None = None, class_name: str | None = None, min_confidence: float | None = None, limit: int = Query( default=DetectionService.DEFAULT_RESULT_LIMIT, ge=0, le=50_000, description="Maximum results to return; 0 returns everything. Highest confidence first.", ), db: Session = Depends(get_db), ) -> dict: if guest_project_scope(request) is not None: dataset = DatasetService.get_dataset(db, dataset_id) assert_guest_project_scope(request, dataset.project_id) return envelope( DetectionService.detections_to_geojson( db, limit=limit, analysis_run_id=analysis_run_id, dataset_id=dataset_id, class_name=class_name, min_confidence=min_confidence, ) ) @router.post("/runs/compare", response_model=Envelope[DetectionComparisonResponse]) def compare_detection_runs(payload: DetectionComparisonRequest, db: Session = Depends(get_db)) -> dict: """Rank several runs against one reference on average precision. The workbench ranks model variants by a stored F1 measured at each variant's own confidence threshold, which orders the thresholds as much as the models. Average precision describes the whole ranking a model produced. Comparability is reported first: runs over different rasters, different references or different inference coverage are not alternatives. """ return envelope( DetectionComparisonService.compare_runs( db, analysis_run_ids=payload.analysis_run_ids, reference_dataset_id=payload.reference_dataset_id, iou_threshold=payload.iou_threshold, ) ) @router.post( "/runs/{analysis_run_id}/qa/reference", response_model=Envelope[AnalysisQaResponse], ) def compare_detection_run_with_reference( analysis_run_id: UUID, payload: DetectionQaRequest, request: Request, db: Session = Depends(get_db), ) -> dict: if guest_project_scope(request) is not None: run = DetectionService.get_run(db, analysis_run_id) assert_guest_project_scope(request, run.project_id) return envelope( DetectionService.compare_detections_with_reference( db=db, analysis_run_id=analysis_run_id, reference_dataset_id=payload.reference_dataset_id, iou_threshold=payload.iou_threshold, class_name=payload.class_name, min_confidence=payload.min_confidence, calibration_thresholds=payload.calibration_thresholds, ) )