Files
geointel/backend/app/services/segmentation_adapter.py
T
Jens faeb58ef6d
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
Initial public release
2026-08-31 21:56:53 +02:00

242 lines
10 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Protocol
from app.core.config import Settings
from app.core.errors import AppError
from app.services.yolo_adapter import _prediction_source, _to_list
@dataclass(frozen=True)
class SegmentationAdapterResult:
class_name: str
confidence: float | None
geometry: dict[str, Any]
bbox_json: dict[str, Any] | None = None
mask_path: str | None = None
source_tile_path: str | None = None
tile_index: int | None = None
properties_json: dict[str, Any] | None = None
provenance_json: dict[str, Any] | None = None
area_m2: float | None = None
class SegmentationAdapter(Protocol):
def segment(self, *args: Any, **kwargs: Any) -> list[SegmentationAdapterResult]:
"""Future segmentation adapters must local-import model dependencies inside execution paths."""
class _UltralyticsSegmentationAdapterBase:
"""Shared local-inference plumbing for ultralytics-backed segmentation models.
Model weights are never downloaded automatically; a missing local file or
missing dependency fails closed with an explicit error.
"""
def __init__(self, settings: Settings) -> None:
self.settings = settings
@staticmethod
def dependencies_available() -> bool:
try:
import torch # noqa: F401
import ultralytics # noqa: F401
except Exception:
return False
return True
def _require_model_file(self, model_path: Path) -> None:
if not model_path.exists() or not model_path.is_file():
raise AppError(
code="SEGMENTATION_MODEL_UNAVAILABLE",
message="Configured segmentation model file does not exist",
details={"model_path": str(model_path)},
status_code=503,
)
if not self.dependencies_available():
raise AppError(
code="SEGMENTATION_DEPENDENCY_UNAVAILABLE",
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():
raise AppError(
code="SEGMENTATION_TILE_NOT_FOUND",
message="Tile referenced by manifest does not exist",
details={"tile_path": str(tile_path)},
status_code=422,
)
try:
with _prediction_source(tile_path) as prediction_source:
return model.predict(
source=prediction_source,
conf=float(confidence_threshold),
imgsz=int(self.settings.yolo_image_size),
device=self.settings.yolo_device,
verbose=False,
)
except AppError:
raise
except Exception as exc:
raise AppError(
code="SEGMENTATION_INFERENCE_FAILED",
message="Configured segmentation inference failed for a raster tile",
details={"tile_path": str(tile_path), "error": str(exc)},
status_code=503,
) from exc
def _extract_masks(self, results: list[Any], default_class_name: str | None = None) -> list[dict[str, Any]]:
segmentations: list[dict[str, Any]] = []
max_masks = int(self.settings.segmentation_max_masks_per_tile)
for result in results:
names = getattr(result, "names", {}) or {}
masks = getattr(result, "masks", None)
if masks is None:
continue
polygons = getattr(masks, "xy", None) or []
boxes = getattr(result, "boxes", None)
confidence_values = _to_list(getattr(boxes, "conf", [])) if boxes is not None else []
class_values = _to_list(getattr(boxes, "cls", [])) if boxes is not None else []
bbox_values = _to_list(getattr(boxes, "xyxy", [])) if boxes is not None else []
for index, polygon in enumerate(polygons):
if len(segmentations) >= max_masks:
return segmentations
points = _to_list(polygon)
if not isinstance(points, list) or len(points) < 3:
continue
class_id = int(class_values[index]) if index < len(class_values) else -1
if default_class_name is not None:
class_name = default_class_name
else:
class_name = str(names.get(class_id, class_id))
confidence = float(confidence_values[index]) if index < len(confidence_values) else None
bbox = [float(value) for value in bbox_values[index]] if index < len(bbox_values) else None
segmentations.append(
{
"class_name": class_name,
"confidence": confidence,
"points": [[float(point[0]), float(point[1])] for point in points],
"bbox": bbox,
"properties": {"class_id": class_id},
}
)
return segmentations
class YoloSegmentationAdapter(_UltralyticsSegmentationAdapterBase):
def load_model(self, model_path: Path):
self._require_model_file(model_path)
try:
from ultralytics import YOLO
except ImportError as exc:
raise AppError(
code="SEGMENTATION_DEPENDENCY_UNAVAILABLE",
message="YOLO segmentation dependencies are not importable. Install backend optional extras with geointel-backend[ai].",
status_code=503,
) from exc
try:
return YOLO(str(model_path))
except Exception as exc:
raise AppError(
code="SEGMENTATION_MODEL_LOAD_FAILED",
message="Configured YOLO segmentation model could not be loaded",
details={"model_path": str(model_path)},
status_code=503,
) from exc
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict[str, Any]]:
results = self._predict(model, tile_path, confidence_threshold)
return self._extract_masks(results)
class SamSegmentationAdapter(_UltralyticsSegmentationAdapterBase):
"""Class-agnostic SAM segmentation through the ultralytics SAM interface."""
def load_model(self, model_path: Path):
self._require_model_file(model_path)
try:
from ultralytics import SAM
except ImportError as exc:
raise AppError(
code="SEGMENTATION_DEPENDENCY_UNAVAILABLE",
message="SAM segmentation requires the ultralytics SAM interface. Install backend optional extras with geointel-backend[ai].",
status_code=503,
) from exc
try:
return SAM(str(model_path))
except Exception as exc:
raise AppError(
code="SEGMENTATION_MODEL_LOAD_FAILED",
message="Configured SAM model could not be loaded",
details={"model_path": str(model_path)},
status_code=503,
) from exc
def predict_tile(self, model, tile_path: Path, confidence_threshold: float) -> list[dict[str, Any]]:
results = self._predict(model, tile_path, confidence_threshold)
return self._extract_masks(results, default_class_name="segment")
class FixtureSegmentationAdapter:
def segment(self, raw_segmentations: Any) -> list[SegmentationAdapterResult]:
if not isinstance(raw_segmentations, list):
return []
results: list[SegmentationAdapterResult] = []
for raw in raw_segmentations:
if not isinstance(raw, dict):
continue
results.append(
SegmentationAdapterResult(
class_name=str(raw.get("class_name") or ""),
confidence=float(raw["confidence"]) if raw.get("confidence") is not None else None,
geometry=raw.get("geometry"),
bbox_json=raw.get("bbox_json"),
mask_path=raw.get("mask_path"),
source_tile_path=raw.get("source_tile_path"),
tile_index=raw.get("tile_index"),
properties_json=raw.get("properties_json"),
provenance_json=raw.get("provenance_json"),
area_m2=raw.get("area_m2"),
)
)
return results