Upgrade async GPU analysis and workbench UX
This commit is contained in:
@@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import Request
|
||||
|
||||
from app.core.errors import AppError
|
||||
|
||||
|
||||
def guest_project_scope(request: Request) -> UUID | None:
|
||||
principal = getattr(request.state, "auth_principal", None)
|
||||
if getattr(principal, "role", None) != "guest":
|
||||
return None
|
||||
project_id = getattr(principal, "project_id", None)
|
||||
if isinstance(project_id, UUID):
|
||||
return project_id
|
||||
raise AppError(
|
||||
code="GUEST_PROJECT_SCOPE_REQUIRED",
|
||||
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
|
||||
def assert_guest_project_scope(request: Request, project_id: UUID) -> None:
|
||||
guest_project_id = guest_project_scope(request)
|
||||
if guest_project_id is not None and project_id != guest_project_id:
|
||||
raise AppError(
|
||||
code="GUEST_PROJECT_SCOPE_REQUIRED",
|
||||
message="Deze gastensessie heeft alleen toegang tot de GeoIntel-demowerkruimte.",
|
||||
status_code=403,
|
||||
)
|
||||
|
||||
|
||||
def guest_scoped_project_filter(
|
||||
request: Request,
|
||||
requested_project_id: UUID | None,
|
||||
) -> UUID | None:
|
||||
guest_project_id = guest_project_scope(request)
|
||||
if guest_project_id is None:
|
||||
return requested_project_id
|
||||
if requested_project_id is not None:
|
||||
assert_guest_project_scope(request, requested_project_id)
|
||||
return guest_project_id
|
||||
@@ -2,9 +2,14 @@ from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
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,
|
||||
@@ -25,6 +30,7 @@ from app.schemas import (
|
||||
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
|
||||
@@ -60,7 +66,12 @@ def get_yolo_preflight(
|
||||
|
||||
|
||||
@router.post("/run", response_model=Envelope[DetectionRunResponse])
|
||||
def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict:
|
||||
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,
|
||||
@@ -76,7 +87,11 @@ def run_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -
|
||||
|
||||
|
||||
@router.post("/run-async", response_model=Envelope[JobRead])
|
||||
def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db)) -> dict:
|
||||
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
|
||||
@@ -84,6 +99,7 @@ def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db))
|
||||
``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,
|
||||
@@ -100,12 +116,14 @@ def queue_detection(payload: DetectionRunRequest, db: Session = Depends(get_db))
|
||||
|
||||
@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
|
||||
@@ -114,8 +132,14 @@ def list_detection_runs(
|
||||
|
||||
|
||||
@router.get("/runs/{analysis_run_id}", response_model=Envelope[DetectionRunRead])
|
||||
def get_detection_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict:
|
||||
return envelope(DetectionService.get_run(db, analysis_run_id).model_dump())
|
||||
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(
|
||||
@@ -124,6 +148,7 @@ def get_detection_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> d
|
||||
)
|
||||
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,
|
||||
@@ -136,6 +161,9 @@ def list_detection_run_detections(
|
||||
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,
|
||||
@@ -155,6 +183,7 @@ def list_detection_run_detections(
|
||||
)
|
||||
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,
|
||||
@@ -167,6 +196,9 @@ def list_dataset_detections(
|
||||
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,
|
||||
@@ -181,8 +213,14 @@ def list_dataset_detections(
|
||||
|
||||
|
||||
@router.get("/detections/{detection_id}", response_model=Envelope[DetectionRead])
|
||||
def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict:
|
||||
return envelope(DetectionService.get_detection(db, detection_id).model_dump())
|
||||
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(
|
||||
@@ -191,6 +229,7 @@ def get_detection(detection_id: UUID, db: Session = Depends(get_db)) -> dict:
|
||||
)
|
||||
def get_detection_run_geojson(
|
||||
analysis_run_id: UUID,
|
||||
request: Request,
|
||||
class_name: str | None = None,
|
||||
min_confidence: float | None = None,
|
||||
limit: int = Query(
|
||||
@@ -201,6 +240,9 @@ def get_detection_run_geojson(
|
||||
),
|
||||
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,
|
||||
@@ -218,6 +260,7 @@ def get_detection_run_geojson(
|
||||
)
|
||||
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,
|
||||
@@ -229,6 +272,9 @@ def get_dataset_detection_geojson(
|
||||
),
|
||||
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,
|
||||
@@ -269,8 +315,12 @@ def compare_detection_runs(payload: DetectionComparisonRequest, db: Session = De
|
||||
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,
|
||||
|
||||
@@ -2,10 +2,11 @@ from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.api.guest_scope import assert_guest_project_scope, guest_project_scope
|
||||
from app.core.errors import AppError
|
||||
from app.db.session import get_db
|
||||
from app.schemas import Envelope
|
||||
@@ -20,13 +21,30 @@ from app.schemas.export import (
|
||||
ReportExportRequest,
|
||||
)
|
||||
from app.services.export_service import ExportService
|
||||
from app.services.dataset_service import DatasetService
|
||||
from app.services.detection_service import DetectionService
|
||||
from app.services.segmentation_service import SegmentationService
|
||||
from app.utils.response import envelope
|
||||
|
||||
router = APIRouter(prefix="/exports", tags=["exports"])
|
||||
|
||||
|
||||
@router.post("/geojson", response_model=Envelope[ExportCreateResponse])
|
||||
def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db)):
|
||||
def export_geojson(
|
||||
payload: GeoJsonExportRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
if guest_project_scope(request) is not None:
|
||||
if payload.export_kind in {"dataset", "vector_selection"} and payload.dataset_id is not None:
|
||||
dataset = DatasetService.get_dataset(db, payload.dataset_id)
|
||||
assert_guest_project_scope(request, dataset.project_id)
|
||||
elif payload.export_kind == "detection_run" and payload.analysis_run_id is not None:
|
||||
run = DetectionService.get_run(db, payload.analysis_run_id)
|
||||
assert_guest_project_scope(request, run.project_id)
|
||||
elif payload.export_kind == "segmentation_run" and payload.analysis_run_id is not None:
|
||||
run = SegmentationService.get_run(db, payload.analysis_run_id)
|
||||
assert_guest_project_scope(request, run.project_id)
|
||||
if payload.export_kind == "vector_selection" and payload.dataset_id is not None and payload.bbox is not None:
|
||||
return envelope(
|
||||
ExportService.export_vector_selection_geojson(
|
||||
@@ -61,17 +79,32 @@ def export_geojson(payload: GeoJsonExportRequest, db: Session = Depends(get_db))
|
||||
|
||||
|
||||
@router.post("/metadata", response_model=Envelope[ExportCreateResponse])
|
||||
def export_project_metadata(payload: MetadataExportRequest, db: Session = Depends(get_db)):
|
||||
def export_project_metadata(
|
||||
payload: MetadataExportRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
assert_guest_project_scope(request, payload.project_id)
|
||||
return envelope(ExportService.export_project_metadata(db, payload.project_id, payload.name).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/report", response_model=Envelope[ExportCreateResponse])
|
||||
def export_project_report(payload: ReportExportRequest, db: Session = Depends(get_db)):
|
||||
def export_project_report(
|
||||
payload: ReportExportRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
assert_guest_project_scope(request, payload.project_id)
|
||||
return envelope(ExportService.export_project_report(db, payload.project_id, payload.name).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.post("/map-result", response_model=Envelope[ExportCreateResponse])
|
||||
def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get_db)):
|
||||
def export_map_result(
|
||||
payload: MapResultExportRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
assert_guest_project_scope(request, payload.project_id)
|
||||
return envelope(ExportService.export_map_result(db, payload).model_dump(mode="json"))
|
||||
|
||||
|
||||
@@ -81,25 +114,35 @@ def export_map_result(payload: MapResultExportRequest, db: Session = Depends(get
|
||||
)
|
||||
def list_project_exports(
|
||||
project_id: UUID,
|
||||
request: Request,
|
||||
limit: int = Query(default=50, ge=1, le=100),
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
assert_guest_project_scope(request, project_id)
|
||||
return envelope(ExportService.list_project_exports(db, project_id, limit=limit, offset=offset).model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/{export_id}", response_model=Envelope[ExportRead])
|
||||
def get_export(export_id: UUID, db: Session = Depends(get_db)):
|
||||
return envelope(ExportService.get_export(db, export_id).model_dump(mode="json"))
|
||||
def get_export(export_id: UUID, request: Request, db: Session = Depends(get_db)):
|
||||
export = ExportService.get_export(db, export_id)
|
||||
assert_guest_project_scope(request, export.project_id)
|
||||
return envelope(export.model_dump(mode="json"))
|
||||
|
||||
|
||||
@router.get("/{export_id}/download")
|
||||
def download_export(export_id: UUID, db: Session = Depends(get_db)):
|
||||
def download_export(export_id: UUID, request: Request, db: Session = Depends(get_db)):
|
||||
if guest_project_scope(request) is not None:
|
||||
export = ExportService.get_export(db, export_id)
|
||||
assert_guest_project_scope(request, export.project_id)
|
||||
path = ExportService.get_export_download_path(db, export_id)
|
||||
media_type = "text/html" if path.suffix.lower() in {".html", ".htm"} else "application/json"
|
||||
return FileResponse(path, filename=path.name, media_type=media_type)
|
||||
|
||||
|
||||
@router.get("/{export_id}/content", response_model=Envelope[ExportContentResponse])
|
||||
def get_export_content(export_id: UUID, db: Session = Depends(get_db)):
|
||||
def get_export_content(export_id: UUID, request: Request, db: Session = Depends(get_db)):
|
||||
if guest_project_scope(request) is not None:
|
||||
export = ExportService.get_export(db, export_id)
|
||||
assert_guest_project_scope(request, export.project_id)
|
||||
return envelope(ExportService.get_export_content(db, export_id).model_dump(mode="json"))
|
||||
|
||||
@@ -2,9 +2,14 @@ from __future__ import annotations
|
||||
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
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,
|
||||
@@ -21,6 +26,7 @@ from app.schemas import (
|
||||
SegmentationRunResponse,
|
||||
)
|
||||
from app.services.model_registry_service import ModelRegistryService
|
||||
from app.services.dataset_service import DatasetService
|
||||
from app.services.detection_service import DetectionService
|
||||
from app.services.segmentation_service import SegmentationService
|
||||
from app.utils.response import envelope
|
||||
@@ -34,7 +40,12 @@ def list_segmentation_models() -> dict:
|
||||
|
||||
|
||||
@router.post("/run", response_model=Envelope[SegmentationRunResponse])
|
||||
def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_db)) -> dict:
|
||||
def run_segmentation(
|
||||
payload: SegmentationRunRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
assert_guest_project_scope(request, payload.project_id)
|
||||
result = SegmentationService.run_segmentation(
|
||||
db=db,
|
||||
project_id=payload.project_id,
|
||||
@@ -49,13 +60,18 @@ def run_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_
|
||||
|
||||
|
||||
@router.post("/run-async", response_model=Envelope[JobRead])
|
||||
def queue_segmentation(payload: SegmentationRunRequest, db: Session = Depends(get_db)) -> dict:
|
||||
def queue_segmentation(
|
||||
payload: SegmentationRunRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
"""Queue a segmentation run for the background worker.
|
||||
|
||||
Configured segmentation walks the same tile manifest as detection and is
|
||||
just as unsuited to running inside the request. Poll ``GET /jobs/{id}``.
|
||||
"""
|
||||
|
||||
assert_guest_project_scope(request, payload.project_id)
|
||||
job = SegmentationService.enqueue_segmentation(
|
||||
db=db,
|
||||
project_id=payload.project_id,
|
||||
@@ -71,12 +87,14 @@ def queue_segmentation(payload: SegmentationRunRequest, db: Session = Depends(ge
|
||||
|
||||
@router.get("/runs", response_model=Envelope[SegmentationRunListResponse])
|
||||
def list_segmentation_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(
|
||||
SegmentationService.list_runs(
|
||||
db, project_id=project_id, dataset_id=dataset_id, limit=limit, offset=offset
|
||||
@@ -85,8 +103,14 @@ def list_segmentation_runs(
|
||||
|
||||
|
||||
@router.get("/runs/{analysis_run_id}", response_model=Envelope[SegmentationRunRead])
|
||||
def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -> dict:
|
||||
return envelope(SegmentationService.get_run(db, analysis_run_id).model_dump())
|
||||
def get_segmentation_run(
|
||||
analysis_run_id: UUID,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
run = SegmentationService.get_run(db, analysis_run_id)
|
||||
assert_guest_project_scope(request, run.project_id)
|
||||
return envelope(run.model_dump())
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -95,6 +119,7 @@ def get_segmentation_run(analysis_run_id: UUID, db: Session = Depends(get_db)) -
|
||||
)
|
||||
def list_segmentation_run_outputs(
|
||||
analysis_run_id: UUID,
|
||||
request: Request,
|
||||
dataset_id: UUID | None = None,
|
||||
class_name: str | None = None,
|
||||
min_confidence: float | None = None,
|
||||
@@ -107,6 +132,9 @@ def list_segmentation_run_outputs(
|
||||
offset: int = Query(default=0, ge=0),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
if guest_project_scope(request) is not None:
|
||||
run = SegmentationService.get_run(db, analysis_run_id)
|
||||
assert_guest_project_scope(request, run.project_id)
|
||||
return envelope(
|
||||
SegmentationService.list_segmentations(
|
||||
db,
|
||||
@@ -126,6 +154,7 @@ def list_segmentation_run_outputs(
|
||||
)
|
||||
def list_dataset_segmentations(
|
||||
dataset_id: UUID,
|
||||
request: Request,
|
||||
analysis_run_id: UUID | None = None,
|
||||
class_name: str | None = None,
|
||||
min_confidence: float | None = None,
|
||||
@@ -138,6 +167,9 @@ def list_dataset_segmentations(
|
||||
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(
|
||||
SegmentationService.list_segmentations(
|
||||
db,
|
||||
@@ -152,8 +184,14 @@ def list_dataset_segmentations(
|
||||
|
||||
|
||||
@router.get("/segmentations/{segmentation_id}", response_model=Envelope[SegmentationRead])
|
||||
def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> dict:
|
||||
return envelope(SegmentationService.get_segmentation(db, segmentation_id).model_dump())
|
||||
def get_segmentation(
|
||||
segmentation_id: UUID,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
segmentation = SegmentationService.get_segmentation(db, segmentation_id)
|
||||
assert_guest_project_scope(request, segmentation.project_id)
|
||||
return envelope(segmentation.model_dump())
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -162,6 +200,7 @@ def get_segmentation(segmentation_id: UUID, db: Session = Depends(get_db)) -> di
|
||||
)
|
||||
def get_segmentation_run_geojson(
|
||||
analysis_run_id: UUID,
|
||||
request: Request,
|
||||
class_name: str | None = None,
|
||||
min_confidence: float | None = None,
|
||||
limit: int = Query(
|
||||
@@ -172,6 +211,9 @@ def get_segmentation_run_geojson(
|
||||
),
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
if guest_project_scope(request) is not None:
|
||||
run = SegmentationService.get_run(db, analysis_run_id)
|
||||
assert_guest_project_scope(request, run.project_id)
|
||||
return envelope(
|
||||
SegmentationService.segmentations_to_geojson(
|
||||
db,
|
||||
@@ -189,6 +231,7 @@ def get_segmentation_run_geojson(
|
||||
)
|
||||
def get_dataset_segmentation_geojson(
|
||||
dataset_id: UUID,
|
||||
request: Request,
|
||||
analysis_run_id: UUID | None = None,
|
||||
class_name: str | None = None,
|
||||
min_confidence: float | None = None,
|
||||
@@ -200,6 +243,9 @@ def get_dataset_segmentation_geojson(
|
||||
),
|
||||
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(
|
||||
SegmentationService.segmentations_to_geojson(
|
||||
db,
|
||||
@@ -219,8 +265,12 @@ def get_dataset_segmentation_geojson(
|
||||
def compare_segmentation_run_with_reference(
|
||||
analysis_run_id: UUID,
|
||||
payload: SegmentationQaRequest,
|
||||
request: Request,
|
||||
db: Session = Depends(get_db),
|
||||
) -> dict:
|
||||
if guest_project_scope(request) is not None:
|
||||
run = SegmentationService.get_run(db, analysis_run_id)
|
||||
assert_guest_project_scope(request, run.project_id)
|
||||
return envelope(
|
||||
SegmentationService.compare_segmentations_with_reference(
|
||||
db=db,
|
||||
|
||||
@@ -264,7 +264,9 @@ def create_app() -> FastAPI:
|
||||
}
|
||||
guest_scoped_analysis_post_paths = {
|
||||
f"{settings.api_prefix}/detection/run",
|
||||
f"{settings.api_prefix}/detection/run-async",
|
||||
f"{settings.api_prefix}/segmentation/run",
|
||||
f"{settings.api_prefix}/segmentation/run-async",
|
||||
f"{settings.api_prefix}/qa/detections-vs-reference",
|
||||
f"{settings.api_prefix}/exports/geojson",
|
||||
f"{settings.api_prefix}/exports/metadata",
|
||||
|
||||
@@ -102,4 +102,9 @@ class SegmentationRead(BaseModel):
|
||||
|
||||
class SegmentationListResponse(BaseModel):
|
||||
items: list[SegmentationRead]
|
||||
# ``total`` describes the complete filtered population; ``items`` is one
|
||||
# stable confidence-ranked page of it.
|
||||
total: int
|
||||
limit: int | None = None
|
||||
offset: int = 0
|
||||
truncated: bool = False
|
||||
|
||||
@@ -18,7 +18,7 @@ import socket
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import HTTPRedirectHandler, build_opener, urlopen
|
||||
from urllib.request import HTTPRedirectHandler, build_opener
|
||||
|
||||
from app.core.errors import AppError
|
||||
|
||||
@@ -38,12 +38,30 @@ class _RejectRedirects(HTTPRedirectHandler):
|
||||
return None
|
||||
|
||||
|
||||
class _ValidatedRedirects(HTTPRedirectHandler):
|
||||
"""Validate a redirect target before urllib opens the next connection."""
|
||||
|
||||
def __init__(self, expected_url: str) -> None:
|
||||
super().__init__()
|
||||
self.expected_url = expected_url
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: ANN001, D102
|
||||
assert_same_origin_redirect(self.expected_url, newurl)
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def no_redirect_opener():
|
||||
"""An opener that will not follow a redirect anywhere."""
|
||||
|
||||
return build_opener(_RejectRedirects())
|
||||
|
||||
|
||||
def validated_redirect_opener(expected_url: str):
|
||||
"""An opener that validates each redirect before following it."""
|
||||
|
||||
return build_opener(_ValidatedRedirects(expected_url))
|
||||
|
||||
|
||||
def _reject(code: str, message: str, **details: Any) -> AppError:
|
||||
return AppError(code=code, message=message, details=details or None, status_code=502)
|
||||
|
||||
@@ -88,6 +106,20 @@ def assert_public_http_url(url: str) -> None:
|
||||
host = parsed.hostname
|
||||
if not host:
|
||||
raise _reject("OUTBOUND_URL_NOT_ALLOWED", "Outbound request has no host.", url=url)
|
||||
if parsed.username is not None or parsed.password is not None:
|
||||
raise _reject(
|
||||
"OUTBOUND_URL_NOT_ALLOWED",
|
||||
"Bounded acquisition refuses credentials embedded in an outbound URL.",
|
||||
host=host,
|
||||
)
|
||||
try:
|
||||
parsed.port
|
||||
except ValueError as error:
|
||||
raise _reject(
|
||||
"OUTBOUND_URL_NOT_ALLOWED",
|
||||
"Outbound request contains an invalid port.",
|
||||
host=host,
|
||||
) from error
|
||||
|
||||
literal = host.strip("[]")
|
||||
candidates = [literal] if _looks_like_ip(literal) else _resolved_addresses(host)
|
||||
@@ -133,15 +165,28 @@ def assert_same_origin_redirect(original_url: str, final_url: str) -> None:
|
||||
"The official endpoint redirected from HTTPS to an unprotected scheme.",
|
||||
redirect_scheme=final.scheme,
|
||||
)
|
||||
# This catches embedded credentials, invalid ports and non-public
|
||||
# resolutions before the redirect handler can construct the next request.
|
||||
assert_public_http_url(final_url)
|
||||
original_port = original.port or (443 if original.scheme == "https" else 80)
|
||||
final_port = final.port or (443 if final.scheme == "https" else 80)
|
||||
same_scheme_port = final.scheme == original.scheme and final_port == original_port
|
||||
safe_https_upgrade = original.scheme == "http" and final.scheme == "https" and final_port == 443
|
||||
if not (same_scheme_port or safe_https_upgrade):
|
||||
raise _reject(
|
||||
"OUTBOUND_REDIRECT_NOT_ALLOWED",
|
||||
"The official endpoint redirected to a different network origin.",
|
||||
expected_port=original_port,
|
||||
redirect_port=final_port,
|
||||
)
|
||||
|
||||
|
||||
def guarded_opener(expected_url: str, *, allow_redirect: bool = True) -> Callable[..., Any]:
|
||||
"""An ``urlopen`` replacement that verifies where the response came from.
|
||||
"""An ``urlopen`` replacement that keeps redirects on the expected origin.
|
||||
|
||||
``urlopen`` has already followed the redirect chain by the time it returns,
|
||||
so the check is on ``response.url``: the body is still unread, and raising
|
||||
here means nothing off-origin is ever parsed or persisted.
|
||||
Redirect targets are validated by the handler *before* urllib opens the
|
||||
next connection. The final response URL is checked again as a defensive
|
||||
invariant for injected/custom transports.
|
||||
|
||||
``allow_redirect=False`` refuses any redirect at all, which is what the
|
||||
paged OGC feature readers want: a page URL they built themselves should be
|
||||
@@ -151,7 +196,11 @@ def guarded_opener(expected_url: str, *, allow_redirect: bool = True) -> Callabl
|
||||
|
||||
assert_public_http_url(expected_url)
|
||||
|
||||
default_transport = urlopen if allow_redirect else no_redirect_opener().open
|
||||
default_transport = (
|
||||
validated_redirect_opener(expected_url).open
|
||||
if allow_redirect
|
||||
else no_redirect_opener().open
|
||||
)
|
||||
|
||||
def _open(request: Any, *args: Any, _transport: Callable[..., Any] | None = None, **kwargs: Any) -> Any:
|
||||
response = (_transport or default_transport)(request, *args, **kwargs)
|
||||
|
||||
@@ -61,6 +61,41 @@ class _UltralyticsSegmentationAdapterBase:
|
||||
message="Segmentation dependencies are not installed. Install backend optional extras with geointel-backend[ai].",
|
||||
status_code=503,
|
||||
)
|
||||
self.validate_runtime()
|
||||
|
||||
def validate_runtime(self) -> None:
|
||||
"""Fail closed when the deployment contract requires NVIDIA CUDA.
|
||||
|
||||
Detection and segmentation share ``YOLO_DEVICE`` and
|
||||
``YOLO_REQUIRE_CUDA``. Without this check segmentation could advertise
|
||||
a GPU job while Ultralytics silently used CPU or failed only after the
|
||||
model had already been loaded.
|
||||
"""
|
||||
|
||||
if not self.settings.yolo_require_cuda:
|
||||
return
|
||||
try:
|
||||
import torch
|
||||
except Exception as exc:
|
||||
raise AppError(
|
||||
code="SEGMENTATION_ACCELERATOR_UNAVAILABLE",
|
||||
message="NVIDIA CUDA is required for configured segmentation, but PyTorch is not importable.",
|
||||
status_code=503,
|
||||
) from exc
|
||||
if not torch.cuda.is_available():
|
||||
raise AppError(
|
||||
code="SEGMENTATION_ACCELERATOR_UNAVAILABLE",
|
||||
message="NVIDIA CUDA is required for configured segmentation, but no CUDA device is available.",
|
||||
details={"configured_device": self.settings.yolo_device},
|
||||
status_code=503,
|
||||
)
|
||||
if not str(self.settings.yolo_device).lower().startswith(("cuda", "0", "1", "2", "3")):
|
||||
raise AppError(
|
||||
code="SEGMENTATION_ACCELERATOR_MISCONFIGURED",
|
||||
message="NVIDIA CUDA is required, but YOLO_DEVICE does not select a CUDA device.",
|
||||
details={"configured_device": self.settings.yolo_device},
|
||||
status_code=503,
|
||||
)
|
||||
|
||||
def _predict(self, model, tile_path: Path, confidence_threshold: float) -> list[Any]:
|
||||
if not tile_path.exists() or not tile_path.is_file():
|
||||
|
||||
@@ -272,6 +272,8 @@ class SegmentationService:
|
||||
dataset_id: uuid.UUID | None = None,
|
||||
class_name: str | None = None,
|
||||
min_confidence: float | None = None,
|
||||
limit: int | None = None,
|
||||
offset: int = 0,
|
||||
) -> SegmentationListResponse:
|
||||
if analysis_run_id is not None:
|
||||
run = db.get(AnalysisRun, analysis_run_id)
|
||||
@@ -284,8 +286,19 @@ class SegmentationService:
|
||||
class_name=class_name,
|
||||
min_confidence=min_confidence,
|
||||
)
|
||||
items = [SegmentationRead.model_validate(row) for row in rows]
|
||||
return SegmentationListResponse(items=items, total=len(items))
|
||||
resolved_limit = DetectionService.DEFAULT_RESULT_LIMIT if limit is None else int(limit)
|
||||
page, total, truncated = DetectionService.paginate(
|
||||
rows,
|
||||
limit=resolved_limit,
|
||||
offset=offset,
|
||||
)
|
||||
return SegmentationListResponse(
|
||||
items=[SegmentationRead.model_validate(row) for row in page],
|
||||
total=total,
|
||||
limit=resolved_limit,
|
||||
offset=max(0, int(offset)),
|
||||
truncated=truncated,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_segmentation(db, segmentation_id: uuid.UUID) -> SegmentationRead:
|
||||
@@ -847,7 +860,6 @@ class SegmentationService:
|
||||
"suppressed_segmentation_count": len(candidates) - len(filtered_candidates),
|
||||
"duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold),
|
||||
"containment_suppression_threshold": float(settings.segmentation_containment_nms_threshold),
|
||||
"duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold),
|
||||
"tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()),
|
||||
"runtime_model_provenance": runtime_model_provenance.as_dict(),
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user