Upgrade async GPU analysis and workbench UX

This commit is contained in:
Jens
2026-08-23 21:50:11 +02:00
parent 4040cbca7b
commit b996986d20
59 changed files with 3999 additions and 274 deletions
+55 -6
View File
@@ -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():
+15 -3
View 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(),
}