GeoIntel release gates / Compile, test, contracts and builds (push) Successful in 1m49s
GeoIntel release gates / Python and npm vulnerability policy (push) Successful in 21s
GeoIntel release gates / Production AI image, SBOM and container scan (push) Successful in 5m39s
GeoIntel release gates / Deploy exact gated revision to Unraid (push) Failing after 58m43s
337 lines
11 KiB
Python
337 lines
11 KiB
Python
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,
|
|
)
|
|
)
|