1363 lines
61 KiB
Python
1363 lines
61 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
import json
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from typing import Type
|
|
|
|
from geoalchemy2.shape import from_shape, to_shape
|
|
from pyproj import Transformer
|
|
from shapely.geometry import box as shapely_box
|
|
from shapely.geometry import mapping, shape
|
|
from shapely.ops import transform as shapely_transform
|
|
from shapely.strtree import STRtree
|
|
from sqlalchemy import func
|
|
|
|
from app.core.config import Settings, get_settings
|
|
from app.core.errors import AppError
|
|
from app.core.request_context import get_request_id
|
|
from app.models import AnalysisRun, Area, Dataset, Detection, Job, Project, VectorFeature
|
|
from app.schemas.detection import DetectionListResponse, DetectionRead, DetectionRunListResponse, DetectionRunRead, DetectionRunResponse
|
|
from app.services.detection_georeferencing import pixel_bbox_to_epsg4326_polygon
|
|
from app.services.detection_metrics_service import DetectionMetricsService
|
|
from app.services.detection_qa_service import DetectionQaService
|
|
from app.services.dataset_consumption_gate_service import DatasetConsumptionGate
|
|
from app.services.model_asset_catalog_service import ModelAssetCatalogService
|
|
from app.services.model_registry_service import ModelRegistryService
|
|
from app.services.model_validation_scope_service import ModelValidationScopeService
|
|
from app.services.qa_service import QaService
|
|
from app.services.storage_service import StorageService
|
|
from app.services.quality_service import QualityService
|
|
from app.services.runtime_model_provenance_service import RuntimeModelProvenance, RuntimeModelProvenanceService
|
|
from app.services.temporal_compatibility_service import TemporalCompatibilityService
|
|
from app.services.tile_manifest_service import TileManifestService
|
|
from app.services.yolo_adapter import YoloDetectionAdapter
|
|
|
|
|
|
logger = logging.getLogger("geointel.detection")
|
|
|
|
|
|
class DetectionService:
|
|
@staticmethod
|
|
def _now() -> datetime:
|
|
return datetime.now(UTC)
|
|
|
|
@staticmethod
|
|
def run_detection(
|
|
db,
|
|
project_id: uuid.UUID,
|
|
dataset_id: uuid.UUID,
|
|
model_id: str,
|
|
confidence_threshold: float,
|
|
model_asset_id: str | None = None,
|
|
class_filter: list[str] | None = None,
|
|
tile_manifest_path: str | None = None,
|
|
parameters_json: dict[str, Any] | None = None,
|
|
settings: Settings | None = None,
|
|
yolo_adapter_class: Type[YoloDetectionAdapter] = YoloDetectionAdapter,
|
|
existing_job: Job | None = None,
|
|
) -> DetectionRunResponse:
|
|
parameters = dict(parameters_json or {})
|
|
resolved_settings = settings or get_settings()
|
|
dataset = DetectionService._validate_run_request(db, project_id=project_id, dataset_id=dataset_id)
|
|
TemporalCompatibilityService.ensure_detection_source_supported(dataset)
|
|
|
|
selected_model_asset = None
|
|
if model_id == resolved_settings.yolo_model_id and model_asset_id:
|
|
selected_model_asset = ModelAssetCatalogService.resolve_asset(model_asset_id, settings=resolved_settings)
|
|
resolved_settings = ModelAssetCatalogService.settings_for_asset(resolved_settings, selected_model_asset)
|
|
|
|
model = ModelRegistryService.get_model_capability(
|
|
model_id,
|
|
settings=resolved_settings,
|
|
yolo_adapter_class=yolo_adapter_class,
|
|
)
|
|
if model is None:
|
|
raise AppError(code="DETECTION_MODEL_NOT_FOUND", message="Detection model not found", status_code=404)
|
|
if model.model_id == "manual-fixture-detector" and parameters.get("fixture_mode") is not True:
|
|
raise AppError(
|
|
code="FIXTURE_MODE_REQUIRED",
|
|
message="Fixture detector requires explicit fixture_mode=true",
|
|
status_code=400,
|
|
)
|
|
if model.model_id == resolved_settings.yolo_model_id and not tile_manifest_path:
|
|
raise AppError(
|
|
code="DETECTION_TILE_MANIFEST_REQUIRED",
|
|
message="Configured YOLO inference requires an existing raster tile manifest path",
|
|
status_code=400,
|
|
)
|
|
requested_classes = {DetectionService._canonical_class_name(value) for value in (class_filter or [])}
|
|
unsupported_classes = sorted(requested_classes - set(model.supported_classes))
|
|
if unsupported_classes:
|
|
raise AppError(code="DETECTION_CLASS_NOT_VALIDATED", message="The selected model is not validated for one or more requested classes", details={"unsupported_classes": unsupported_classes, "supported_classes": model.supported_classes}, status_code=422)
|
|
if model.model_id == resolved_settings.yolo_model_id and resolved_settings.yolo_enforce_validation_scope:
|
|
DetectionService._validate_model_area_scope(db, dataset, resolved_settings)
|
|
|
|
# Never enter a production inference path with a persisted dataset
|
|
# that has failed validation, incomplete provenance, or an active
|
|
# quarantine. Fixture detection is a separate QA/test-only path.
|
|
if model.model_id == "manual-fixture-detector":
|
|
DatasetConsumptionGate.assert_eligible(
|
|
dataset,
|
|
purpose="quality_assessment",
|
|
fixture_mode=True,
|
|
)
|
|
elif model.model_id == resolved_settings.yolo_model_id and model.configured:
|
|
DatasetConsumptionGate.assert_eligible(dataset, purpose="production_inference")
|
|
|
|
run_parameters = {
|
|
"model_id": model.model_id,
|
|
"model_asset_id": selected_model_asset.model_asset_id if selected_model_asset else None,
|
|
"model_asset_path": selected_model_asset.model_path if selected_model_asset else None,
|
|
"model_asset_sha256": selected_model_asset.sha256 if selected_model_asset else None,
|
|
"confidence_threshold": confidence_threshold,
|
|
"class_filter": class_filter or [],
|
|
"tile_manifest_path": tile_manifest_path,
|
|
"parameters_json": parameters,
|
|
}
|
|
job = DetectionService._create_job(db, project_id, dataset_id, run_parameters, existing_job=existing_job)
|
|
analysis_run = DetectionService._create_analysis_run(db, project_id, dataset_id, job.id, model, run_parameters)
|
|
logger.info(
|
|
"detection_started request_id=%s project_id=%s dataset_id=%s job_id=%s analysis_run_id=%s model_id=%s",
|
|
get_request_id(),
|
|
project_id,
|
|
dataset_id,
|
|
job.id,
|
|
analysis_run.id,
|
|
model.model_id,
|
|
)
|
|
|
|
if not model.configured:
|
|
message = model.limitation_message
|
|
code = "DETECTION_DEPENDENCY_UNAVAILABLE" if model.status == "dependency_unavailable" else "DETECTION_MODEL_UNAVAILABLE"
|
|
DetectionService._mark_failed(db, analysis_run, job, code=code, message=message)
|
|
return DetectionRunResponse(
|
|
analysis_run_id=analysis_run.id,
|
|
job_id=job.id,
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
model_id=model.model_id,
|
|
status="failed",
|
|
detection_count=0,
|
|
error_code=code,
|
|
message=message,
|
|
)
|
|
|
|
if model.model_id == "manual-fixture-detector":
|
|
try:
|
|
detections = DetectionService._persist_fixture_detections(
|
|
db=db,
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
analysis_run=analysis_run,
|
|
job=job,
|
|
model_name=model.model_id,
|
|
model_version=model.version,
|
|
raw_detections=parameters.get("fixture_detections"),
|
|
confidence_threshold=confidence_threshold,
|
|
class_filter=class_filter or [],
|
|
)
|
|
except Exception as exc:
|
|
# A rejected fixture payload must never leave the run stuck in "running".
|
|
DetectionService._fail_run_after_exception(db, analysis_run, job, exc, fallback_code="DETECTION_INTERNAL_ERROR")
|
|
raise
|
|
DetectionService._mark_success(db, analysis_run, job, detection_count=len(detections))
|
|
return DetectionRunResponse(
|
|
analysis_run_id=analysis_run.id,
|
|
job_id=job.id,
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
model_id=model.model_id,
|
|
status="success",
|
|
detection_count=len(detections),
|
|
message="Fixture detections persisted.",
|
|
)
|
|
|
|
if model.model_id == resolved_settings.yolo_model_id:
|
|
try:
|
|
detections, postprocess_summary = DetectionService._run_configured_yolo(
|
|
db=db,
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
analysis_run=analysis_run,
|
|
job=job,
|
|
model_name=model.model_id,
|
|
model_version=model.version,
|
|
tile_manifest_path=tile_manifest_path,
|
|
confidence_threshold=confidence_threshold,
|
|
class_filter=class_filter or [],
|
|
settings=resolved_settings,
|
|
yolo_adapter_class=yolo_adapter_class,
|
|
)
|
|
except AppError as exc:
|
|
DetectionService._mark_failed(db, analysis_run, job, code=exc.code, message=exc.message)
|
|
return DetectionRunResponse(
|
|
analysis_run_id=analysis_run.id,
|
|
job_id=job.id,
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
model_id=model.model_id,
|
|
status="failed",
|
|
detection_count=0,
|
|
error_code=exc.code,
|
|
message=exc.message,
|
|
)
|
|
except Exception as exc:
|
|
# An unexpected inference error must never leave the run stuck in "running".
|
|
DetectionService._fail_run_after_exception(db, analysis_run, job, exc, fallback_code="DETECTION_INTERNAL_ERROR")
|
|
raise
|
|
DetectionService._mark_success(db, analysis_run, job, detection_count=len(detections), extra_result=postprocess_summary)
|
|
return DetectionRunResponse(
|
|
analysis_run_id=analysis_run.id,
|
|
job_id=job.id,
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
model_id=model.model_id,
|
|
status="success",
|
|
detection_count=len(detections),
|
|
message="YOLO detections persisted.",
|
|
)
|
|
|
|
DetectionService._mark_failed(
|
|
db,
|
|
analysis_run,
|
|
job,
|
|
code="DETECTION_MODEL_UNAVAILABLE",
|
|
message="Detection model is unavailable",
|
|
)
|
|
raise AppError(code="DETECTION_MODEL_UNAVAILABLE", message="Detection model is unavailable", status_code=503)
|
|
|
|
@staticmethod
|
|
def _validate_model_area_scope(db, dataset: Dataset, settings: Settings) -> None:
|
|
area = db.get(Area, dataset.area_id) if dataset.area_id else None
|
|
if area is None or area.geometry is None:
|
|
raise AppError(
|
|
code="DETECTION_VALIDATION_SCOPE_UNAVAILABLE",
|
|
message="Configured YOLO inference requires a persisted Dataset area geometry.",
|
|
details={"dataset_id": str(dataset.id)},
|
|
status_code=422,
|
|
)
|
|
try:
|
|
area_geometry = to_shape(area.geometry)
|
|
except Exception as exc:
|
|
raise AppError(
|
|
code="DETECTION_VALIDATION_SCOPE_UNAVAILABLE",
|
|
message="The persisted Dataset area geometry cannot be validated for model inference.",
|
|
details={"dataset_id": str(dataset.id), "error_type": type(exc).__name__},
|
|
status_code=422,
|
|
) from exc
|
|
ModelValidationScopeService.assert_area_covered(
|
|
area_geometry=area_geometry,
|
|
manifest_path=settings.yolo_validation_scope_manifest_path,
|
|
expected_manifest_sha256=settings.yolo_validation_scope_manifest_sha256,
|
|
model_id=settings.yolo_model_id,
|
|
model_path=settings.yolo_model_path,
|
|
)
|
|
@staticmethod
|
|
def _fail_run_after_exception(db, analysis_run: AnalysisRun, job: Job, exc: Exception, fallback_code: str) -> None:
|
|
try:
|
|
db.rollback()
|
|
except Exception:
|
|
pass
|
|
code = getattr(exc, "code", None) or fallback_code
|
|
message = getattr(exc, "message", None) or "Unexpected internal error during analysis run"
|
|
try:
|
|
DetectionService._mark_failed(db, analysis_run, job, code=str(code), message=str(message))
|
|
except Exception:
|
|
pass
|
|
|
|
@staticmethod
|
|
def get_run(db, analysis_run_id: uuid.UUID) -> DetectionRunRead:
|
|
run = db.get(AnalysisRun, analysis_run_id)
|
|
if not run or run.analysis_type != "detection":
|
|
raise AppError(code="DETECTION_RUN_NOT_FOUND", message="Detection run not found", status_code=404)
|
|
return DetectionRunRead.model_validate(run)
|
|
|
|
@staticmethod
|
|
def list_runs(
|
|
db,
|
|
*,
|
|
project_id: uuid.UUID | None = None,
|
|
dataset_id: uuid.UUID | None = None,
|
|
limit: int | None = None,
|
|
offset: int = 0,
|
|
) -> DetectionRunListResponse:
|
|
query = db.query(AnalysisRun).filter(AnalysisRun.analysis_type == "detection")
|
|
if project_id is not None:
|
|
query = query.filter(AnalysisRun.project_id == project_id)
|
|
if dataset_id is not None:
|
|
query = query.filter(AnalysisRun.dataset_id == dataset_id)
|
|
rows = query.order_by(AnalysisRun.created_at.desc()).all()
|
|
# Runs accumulate with every analysis; the panel draws the recent ones.
|
|
resolved_limit = DetectionService.DEFAULT_RUN_LIST_LIMIT if limit is None else int(limit)
|
|
page, total, truncated = DetectionService.paginate(rows, limit=resolved_limit, offset=offset)
|
|
return DetectionRunListResponse(
|
|
items=[DetectionRunRead.model_validate(row) for row in page],
|
|
total=total,
|
|
limit=resolved_limit,
|
|
offset=max(0, int(offset)),
|
|
truncated=truncated,
|
|
)
|
|
|
|
@staticmethod
|
|
def list_detections(
|
|
db,
|
|
analysis_run_id: uuid.UUID | None = None,
|
|
*,
|
|
dataset_id: uuid.UUID | None = None,
|
|
class_name: str | None = None,
|
|
min_confidence: float | None = None,
|
|
limit: int | None = None,
|
|
offset: int = 0,
|
|
) -> DetectionListResponse:
|
|
if analysis_run_id is not None:
|
|
run = db.get(AnalysisRun, analysis_run_id)
|
|
if not run or run.analysis_type != "detection":
|
|
raise AppError(code="DETECTION_RUN_NOT_FOUND", message="Detection run not found", status_code=404)
|
|
rows = DetectionService._query_detection_rows(
|
|
db,
|
|
analysis_run_id=analysis_run_id,
|
|
dataset_id=dataset_id,
|
|
class_name=class_name,
|
|
min_confidence=min_confidence,
|
|
)
|
|
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 DetectionListResponse(
|
|
items=[DetectionRead.model_validate(row) for row in page],
|
|
total=total,
|
|
limit=resolved_limit,
|
|
offset=max(0, int(offset)),
|
|
truncated=truncated,
|
|
)
|
|
|
|
@staticmethod
|
|
def get_detection(db, detection_id: uuid.UUID) -> DetectionRead:
|
|
detection = db.get(Detection, detection_id)
|
|
if not detection:
|
|
raise AppError(code="DETECTION_NOT_FOUND", message="Detection not found", status_code=404)
|
|
return DetectionRead.model_validate(detection)
|
|
|
|
@staticmethod
|
|
def detections_to_geojson(
|
|
db,
|
|
*,
|
|
analysis_run_id: uuid.UUID | None = None,
|
|
dataset_id: uuid.UUID | None = None,
|
|
class_name: str | None = None,
|
|
min_confidence: float | None = None,
|
|
limit: int | None = None,
|
|
) -> dict[str, Any]:
|
|
rows = DetectionService._query_detection_rows(
|
|
db,
|
|
analysis_run_id=analysis_run_id,
|
|
dataset_id=dataset_id,
|
|
class_name=class_name,
|
|
min_confidence=min_confidence,
|
|
)
|
|
resolved_limit = DetectionService.DEFAULT_RESULT_LIMIT if limit is None else int(limit)
|
|
# Rows arrive ranked by confidence, so a capped overlay draws the
|
|
# strongest detections rather than an arbitrary slice.
|
|
detections, total, truncated = DetectionService.paginate(rows, limit=resolved_limit, offset=0)
|
|
return {
|
|
"type": "FeatureCollection",
|
|
"geointel_result_window": {
|
|
"feature_count": len(detections),
|
|
"total_feature_count": total,
|
|
"limit": resolved_limit,
|
|
"truncated": truncated,
|
|
},
|
|
"features": [
|
|
{
|
|
"type": "Feature",
|
|
"id": str(detection.id),
|
|
"properties": DetectionService._detection_properties(detection),
|
|
"geometry": mapping(to_shape(detection.geometry)),
|
|
}
|
|
for detection in detections
|
|
],
|
|
}
|
|
|
|
@staticmethod
|
|
def compare_detections_with_reference(
|
|
db,
|
|
analysis_run_id: uuid.UUID,
|
|
reference_dataset_id: uuid.UUID,
|
|
iou_threshold: float = 0.5,
|
|
class_name: str | None = None,
|
|
min_confidence: float | None = None,
|
|
calibration_thresholds: list[float] | None = None,
|
|
) -> dict[str, Any]:
|
|
run = db.get(AnalysisRun, analysis_run_id)
|
|
if not run or run.analysis_type != "detection":
|
|
raise AppError(code="DETECTION_RUN_NOT_FOUND", message="Detection run not found", status_code=404)
|
|
reference_dataset = db.get(Dataset, reference_dataset_id)
|
|
if not reference_dataset:
|
|
raise AppError(code="DATASET_NOT_FOUND", message="Reference dataset not found", status_code=404)
|
|
if reference_dataset.project_id != run.project_id:
|
|
raise AppError(code="INVALID_DATASET_SCOPE", message="Reference dataset does not belong to detection project", status_code=400)
|
|
if reference_dataset.dataset_type not in {"vector", "geojson"}:
|
|
raise AppError(code="INVALID_DATASET_TYPE", message="Reference dataset must be vector data", status_code=400)
|
|
candidate_dataset = db.get(Dataset, run.dataset_id)
|
|
if not candidate_dataset:
|
|
raise AppError(code="DATASET_NOT_FOUND", message="Detection source dataset not found", status_code=404)
|
|
temporal_compatibility = TemporalCompatibilityService.assess_detection_qa(
|
|
candidate_dataset,
|
|
reference_dataset,
|
|
)
|
|
|
|
run_parameters = run.parameters_json if isinstance(run.parameters_json, dict) else {}
|
|
manifest_path = DetectionQaService.tile_manifest_path(run_parameters)
|
|
resolved_settings = get_settings()
|
|
is_configured_yolo = (
|
|
run_parameters.get("model_id") == resolved_settings.yolo_model_id
|
|
or run.model_name == resolved_settings.yolo_model_id
|
|
)
|
|
if is_configured_yolo and not manifest_path:
|
|
raise AppError(
|
|
code="DETECTION_QA_COVERAGE_UNAVAILABLE",
|
|
message="Configured YOLO QA requires persisted tile manifest provenance",
|
|
status_code=422,
|
|
)
|
|
|
|
fixture_parameters = run_parameters.get("parameters_json")
|
|
fixture_mode = bool(
|
|
run.model_name == "manual-fixture-detector"
|
|
and isinstance(fixture_parameters, dict)
|
|
and fixture_parameters.get("fixture_mode") is True
|
|
)
|
|
DatasetConsumptionGate.assert_eligible(
|
|
candidate_dataset,
|
|
purpose="quality_assessment",
|
|
fixture_mode=fixture_mode,
|
|
)
|
|
DatasetConsumptionGate.assert_eligible(
|
|
reference_dataset,
|
|
purpose="reference_validation",
|
|
reference_task="building_validation",
|
|
)
|
|
|
|
detections = DetectionService._query_detection_rows(
|
|
db,
|
|
analysis_run_id=analysis_run_id,
|
|
dataset_id=run.dataset_id,
|
|
class_name=class_name,
|
|
min_confidence=min_confidence,
|
|
)
|
|
raw_candidate_geometries = [
|
|
(
|
|
{
|
|
"id": str(row.id),
|
|
"class_name": row.class_name,
|
|
# Confidence lets the matcher rank candidates the way
|
|
# detection benchmarks do instead of by row order.
|
|
"confidence": row.confidence,
|
|
},
|
|
to_shape(row.geometry),
|
|
)
|
|
for row in detections
|
|
]
|
|
candidate_geometries = raw_candidate_geometries
|
|
|
|
coverage = None
|
|
if manifest_path:
|
|
manifest = DetectionService._load_tile_manifest(manifest_path, resolved_settings.yolo_max_tiles, resolved_settings)
|
|
coverage = DetectionQaService.build_tile_coverage(
|
|
manifest,
|
|
manifest_path=manifest_path,
|
|
expected_dataset_id=run.dataset_id,
|
|
)
|
|
|
|
reference_query = db.query(VectorFeature).filter(VectorFeature.dataset_id == reference_dataset_id)
|
|
if coverage is not None and hasattr(reference_query, "count"):
|
|
reference_raw_count = reference_query.count()
|
|
references = reference_query.filter(
|
|
func.ST_Intersects(VectorFeature.geometry, from_shape(coverage.geometry, srid=4326))
|
|
).all()
|
|
else:
|
|
references = reference_query.all()
|
|
reference_raw_count = len(references)
|
|
if reference_raw_count == 0:
|
|
raise AppError(
|
|
code="REFERENCE_FEATURES_NOT_FOUND",
|
|
message="Reference dataset has no persisted vector features for QA",
|
|
status_code=422,
|
|
)
|
|
|
|
raw_reference_geometries = [({"id": str(row.id), "feature_class": row.feature_class}, to_shape(row.geometry)) for row in references]
|
|
reference_geometries = raw_reference_geometries
|
|
|
|
coverage_summary: dict[str, Any] = {
|
|
"applied": False,
|
|
"mode": "unbounded_no_manifest",
|
|
"manifest_path": None,
|
|
"tile_count": 0,
|
|
"source_crs_values": [],
|
|
"candidate_raw_count": len(raw_candidate_geometries),
|
|
"candidate_evaluated_count": len(raw_candidate_geometries),
|
|
"candidate_excluded_outside_count": 0,
|
|
"candidate_clipped_boundary_count": 0,
|
|
"reference_raw_count": reference_raw_count,
|
|
"reference_evaluated_count": len(raw_reference_geometries),
|
|
"reference_excluded_outside_count": 0,
|
|
"reference_clipped_boundary_count": 0,
|
|
}
|
|
coverage_warnings: list[str] = []
|
|
if coverage is not None:
|
|
candidate_population = DetectionQaService.filter_population(raw_candidate_geometries, coverage)
|
|
reference_population = DetectionQaService.filter_population(
|
|
raw_reference_geometries,
|
|
coverage,
|
|
raw_count=reference_raw_count,
|
|
)
|
|
candidate_geometries = candidate_population.geometries
|
|
reference_geometries = reference_population.geometries
|
|
if not reference_geometries:
|
|
raise AppError(
|
|
code="REFERENCE_FEATURES_OUTSIDE_COVERAGE",
|
|
message="Reference dataset has no polygon features inside persisted inference tile coverage",
|
|
status_code=422,
|
|
)
|
|
coverage_summary = {
|
|
"applied": True,
|
|
"mode": "persisted_tile_manifest_union",
|
|
"manifest_path": coverage.manifest_path,
|
|
"tile_count": coverage.tile_count,
|
|
"source_crs_values": list(coverage.source_crs_values),
|
|
"candidate_raw_count": candidate_population.raw_count,
|
|
"candidate_evaluated_count": candidate_population.evaluated_count,
|
|
"candidate_excluded_outside_count": candidate_population.excluded_outside_count,
|
|
"candidate_clipped_boundary_count": candidate_population.clipped_boundary_count,
|
|
"reference_raw_count": reference_population.raw_count,
|
|
"reference_evaluated_count": reference_population.evaluated_count,
|
|
"reference_excluded_outside_count": reference_population.excluded_outside_count,
|
|
"reference_clipped_boundary_count": reference_population.clipped_boundary_count,
|
|
}
|
|
coverage_warnings.append(
|
|
"QA populations were clipped to the union of persisted inference tile footprints before matching."
|
|
)
|
|
evidence = QaService._match_io_u_evidence(
|
|
candidate_geometries,
|
|
reference_geometries,
|
|
iou_threshold,
|
|
)
|
|
reference_envelopes = [(feature, geometry.envelope) for feature, geometry in reference_geometries]
|
|
envelope_evidence = QaService._match_io_u_evidence(
|
|
candidate_geometries,
|
|
reference_envelopes,
|
|
iou_threshold,
|
|
)
|
|
candidate_geometry_mode = DetectionQaService.candidate_geometry_mode(candidate_geometries)
|
|
box_to_footprint_diagnostics = DetectionQaService.box_to_footprint_diagnostics(
|
|
evidence,
|
|
envelope_evidence,
|
|
iou_threshold=iou_threshold,
|
|
candidate_geometry_mode=candidate_geometry_mode,
|
|
)
|
|
box_to_footprint_diagnostics["envelope_precision_recall_curve"] = (
|
|
DetectionMetricsService.precision_recall_curve(
|
|
candidate_geometries,
|
|
reference_envelopes,
|
|
iou_threshold=iou_threshold,
|
|
)
|
|
)
|
|
if candidate_geometry_mode == "axis_aligned_boxes":
|
|
coverage_warnings.append(
|
|
"Candidates are axis-aligned detector boxes; strict footprint IoU cannot reach 1 for "
|
|
"rotated or non-rectangular buildings. See box_to_footprint_diagnostics."
|
|
)
|
|
# Threshold-independent view of the same populations, so the run can be
|
|
# compared with another model instead of only with itself.
|
|
precision_recall_curve = DetectionMetricsService.precision_recall_curve(
|
|
candidate_geometries,
|
|
reference_geometries,
|
|
iou_threshold=iou_threshold,
|
|
)
|
|
# Every requested confidence cut, answered from that one matching pass.
|
|
# Re-running inference per threshold spends N GPU passes to reproduce
|
|
# numbers already present here: suppression walks candidates in
|
|
# descending confidence, so the kept set above a cut does not depend on
|
|
# the threshold the run itself used.
|
|
calibration_sweep = DetectionMetricsService.calibration_sweep(
|
|
precision_recall_curve,
|
|
thresholds=list(calibration_thresholds or []),
|
|
)
|
|
mean_iou = None if not evidence.match_iou_values else sum(evidence.match_iou_values) / len(evidence.match_iou_values)
|
|
precision = evidence.matches / (evidence.matches + evidence.false_positives) if evidence.matches + evidence.false_positives > 0 else None
|
|
recall = evidence.matches / (evidence.matches + evidence.false_negatives) if evidence.matches + evidence.false_negatives > 0 else None
|
|
f1_score = None
|
|
if precision is not None and recall is not None:
|
|
f1_score = (2 * precision * recall) / (precision + recall) if precision + recall > 0 else 0.0
|
|
status = "unsupported" if evidence.unsupported else "ok"
|
|
quality_check = QualityService.persist_quality_check(
|
|
db=db,
|
|
project_id=run.project_id,
|
|
analysis_run_id=analysis_run_id,
|
|
candidate_dataset_id=run.dataset_id,
|
|
reference_dataset_id=reference_dataset_id,
|
|
check_type="detections_vs_reference",
|
|
status=status,
|
|
score=f1_score,
|
|
parameters={
|
|
"analysis_run_id": str(analysis_run_id),
|
|
"reference_dataset_id": str(reference_dataset_id),
|
|
"iou_threshold": iou_threshold,
|
|
"class_name": class_name,
|
|
"min_confidence": min_confidence,
|
|
"coverage_policy": coverage_summary["mode"],
|
|
"temporal_compatibility": temporal_compatibility,
|
|
},
|
|
findings={
|
|
"matches": evidence.matches,
|
|
"false_positives": evidence.false_positives,
|
|
"false_negatives": evidence.false_negatives,
|
|
"warnings": coverage_warnings + evidence.warnings,
|
|
"unsupported_geometry": evidence.unsupported,
|
|
"coverage": coverage_summary,
|
|
"temporal_compatibility": temporal_compatibility,
|
|
"box_to_footprint_diagnostics": box_to_footprint_diagnostics,
|
|
"precision_recall_curve": precision_recall_curve,
|
|
"calibration_sweep": calibration_sweep,
|
|
"match_evidence": evidence.match_evidence,
|
|
"false_positive_evidence": evidence.false_positive_evidence,
|
|
"false_negative_evidence": evidence.false_negative_evidence,
|
|
},
|
|
metrics={
|
|
"precision": precision,
|
|
"recall": recall,
|
|
"f1": f1_score,
|
|
"mean_iou": mean_iou,
|
|
"false_positive_count": evidence.false_positives,
|
|
"false_negative_count": evidence.false_negatives,
|
|
"average_precision": precision_recall_curve["average_precision"],
|
|
"best_f1": precision_recall_curve["best_f1"],
|
|
"best_f1_threshold": precision_recall_curve["best_f1_threshold"],
|
|
},
|
|
)
|
|
logger.info(
|
|
"detection_qa_completed request_id=%s job_id=%s analysis_run_id=%s quality_check_id=%s "
|
|
"candidate_dataset_id=%s reference_dataset_id=%s status=%s",
|
|
get_request_id(),
|
|
run.job_id,
|
|
analysis_run_id,
|
|
quality_check.id,
|
|
run.dataset_id,
|
|
reference_dataset_id,
|
|
status,
|
|
)
|
|
return {
|
|
"status": status,
|
|
"quality_check_id": str(quality_check.id),
|
|
"analysis_run_id": str(analysis_run_id),
|
|
"reference_dataset_id": str(reference_dataset_id),
|
|
"candidate_feature_count": len(candidate_geometries),
|
|
"reference_feature_count": len(reference_geometries),
|
|
"candidate_feature_count_raw": len(raw_candidate_geometries),
|
|
"reference_feature_count_raw": reference_raw_count,
|
|
"matches": evidence.matches,
|
|
"false_positives": evidence.false_positives,
|
|
"false_negatives": evidence.false_negatives,
|
|
"precision": precision,
|
|
"recall": recall,
|
|
"f1_score": f1_score,
|
|
"mean_iou": mean_iou,
|
|
"iou_threshold": iou_threshold,
|
|
"warnings": coverage_warnings + evidence.warnings,
|
|
"coverage": coverage_summary,
|
|
"temporal_compatibility": temporal_compatibility,
|
|
"box_to_footprint_diagnostics": box_to_footprint_diagnostics,
|
|
"precision_recall_curve": precision_recall_curve,
|
|
"calibration_sweep": calibration_sweep,
|
|
"match_evidence": evidence.match_evidence,
|
|
"false_positive_evidence": evidence.false_positive_evidence,
|
|
"false_negative_evidence": evidence.false_negative_evidence,
|
|
}
|
|
|
|
@staticmethod
|
|
def _create_job(
|
|
db,
|
|
project_id: uuid.UUID,
|
|
dataset_id: uuid.UUID,
|
|
parameters: dict[str, Any],
|
|
existing_job: Job | None = None,
|
|
) -> Job:
|
|
if existing_job is not None:
|
|
# A queued job already represents this run; reuse it so the client
|
|
# keeps polling one identifier from request to result.
|
|
existing_job.status = "running"
|
|
existing_job.dataset_id = dataset_id
|
|
existing_job.input_dataset_id = dataset_id
|
|
existing_job.parameters_json = {**(existing_job.parameters_json or {}), **parameters}
|
|
existing_job.started_at = DetectionService._now()
|
|
db.add(existing_job)
|
|
db.commit()
|
|
db.refresh(existing_job)
|
|
return existing_job
|
|
job = Job(
|
|
id=uuid.uuid4(),
|
|
job_type="detection.run",
|
|
status="running",
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
input_dataset_id=dataset_id,
|
|
parameters_json=parameters,
|
|
started_at=DetectionService._now(),
|
|
)
|
|
db.add(job)
|
|
db.commit()
|
|
db.refresh(job)
|
|
return job
|
|
|
|
@staticmethod
|
|
def enqueue_detection(
|
|
db,
|
|
project_id: uuid.UUID,
|
|
dataset_id: uuid.UUID,
|
|
model_id: str,
|
|
confidence_threshold: float,
|
|
model_asset_id: str | None = None,
|
|
class_filter: list[str] | None = None,
|
|
tile_manifest_path: str | None = None,
|
|
parameters_json: dict[str, Any] | None = None,
|
|
) -> Job:
|
|
"""Accept a detection run for background execution.
|
|
|
|
Everything cheap enough to answer inside the request is checked here,
|
|
so an operator learns about a missing dataset or an unvalidated class
|
|
immediately rather than from a job that fails minutes later.
|
|
"""
|
|
|
|
DetectionService._validate_run_request(
|
|
db,
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
)
|
|
job = Job(
|
|
id=uuid.uuid4(),
|
|
job_type="detection.run",
|
|
status="queued",
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
input_dataset_id=dataset_id,
|
|
parameters_json={
|
|
"project_id": str(project_id),
|
|
"dataset_id": str(dataset_id),
|
|
"model_id": model_id,
|
|
"model_asset_id": model_asset_id,
|
|
"confidence_threshold": confidence_threshold,
|
|
"class_filter": class_filter or [],
|
|
"tile_manifest_path": tile_manifest_path,
|
|
"parameters_json": dict(parameters_json or {}),
|
|
},
|
|
)
|
|
db.add(job)
|
|
db.commit()
|
|
db.refresh(job)
|
|
logger.info(
|
|
"detection_queued request_id=%s project_id=%s dataset_id=%s job_id=%s model_id=%s",
|
|
get_request_id(),
|
|
project_id,
|
|
dataset_id,
|
|
job.id,
|
|
model_id,
|
|
)
|
|
return job
|
|
|
|
@staticmethod
|
|
def _validate_run_request(db, *, project_id: uuid.UUID, dataset_id: uuid.UUID) -> Dataset:
|
|
project = db.get(Project, project_id)
|
|
if not project:
|
|
raise AppError(code="PROJECT_NOT_FOUND", message="Project not found", status_code=404)
|
|
dataset = db.get(Dataset, dataset_id)
|
|
if not dataset or dataset.project_id != project_id:
|
|
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
|
|
if dataset.dataset_type != "raster":
|
|
raise AppError(
|
|
code="INVALID_DATASET_TYPE",
|
|
message="Detection requires a raster dataset",
|
|
details={"dataset_type": dataset.dataset_type},
|
|
status_code=400,
|
|
)
|
|
return dataset
|
|
|
|
# A regional run holds tens of thousands of detections; the results table
|
|
# and the map overlay both read them after every run.
|
|
DEFAULT_RESULT_LIMIT = 2_000
|
|
DEFAULT_RUN_LIST_LIMIT = 200
|
|
|
|
@staticmethod
|
|
def paginate(rows: list[Any], *, limit: int, offset: int) -> tuple[list[Any], int, bool]:
|
|
"""Slice a result population, keeping the total intact.
|
|
|
|
``limit <= 0`` means "everything", for callers that genuinely need the
|
|
whole population and know what they are asking for.
|
|
"""
|
|
|
|
total = len(rows)
|
|
start = max(0, int(offset))
|
|
if limit <= 0:
|
|
return rows[start:], total, False
|
|
page = rows[start : start + int(limit)]
|
|
# Truncated means: this page is not the whole population.
|
|
return page, total, len(page) < total
|
|
|
|
@staticmethod
|
|
def _query_detection_rows(
|
|
db,
|
|
*,
|
|
analysis_run_id: uuid.UUID | None = None,
|
|
dataset_id: uuid.UUID | None = None,
|
|
class_name: str | None = None,
|
|
min_confidence: float | None = None,
|
|
) -> list[Detection]:
|
|
query = db.query(Detection)
|
|
if analysis_run_id is not None:
|
|
query = query.filter(Detection.analysis_run_id == analysis_run_id)
|
|
if dataset_id is not None:
|
|
query = query.filter(Detection.dataset_id == dataset_id)
|
|
if class_name:
|
|
query = query.filter(Detection.class_name == class_name)
|
|
if min_confidence is not None:
|
|
query = query.filter(Detection.confidence >= min_confidence)
|
|
# ``created_at`` defaults to the transaction timestamp, so every
|
|
# detection in a run shares one value and ordering by it alone leaves
|
|
# the row order undefined. Confidence first, id as a stable tiebreak.
|
|
return query.order_by(
|
|
Detection.confidence.desc(),
|
|
Detection.created_at.desc(),
|
|
Detection.id.asc(),
|
|
).all()
|
|
|
|
@staticmethod
|
|
def _detection_properties(detection: Detection) -> dict[str, Any]:
|
|
return {
|
|
"detection_id": str(detection.id),
|
|
"class_name": detection.class_name,
|
|
"confidence": detection.confidence,
|
|
"model_name": detection.model_name,
|
|
"model_version": detection.model_version,
|
|
"analysis_run_id": str(detection.analysis_run_id) if detection.analysis_run_id else None,
|
|
"dataset_id": str(detection.dataset_id) if detection.dataset_id else None,
|
|
"job_id": str(detection.job_id) if detection.job_id else None,
|
|
"source_tile_path": detection.source_tile_path,
|
|
"bbox_json": detection.bbox_json,
|
|
}
|
|
|
|
@staticmethod
|
|
def _create_analysis_run(db, project_id, dataset_id, job_id, model, parameters: dict[str, Any]) -> AnalysisRun:
|
|
analysis_run = AnalysisRun(
|
|
id=uuid.uuid4(),
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
job_id=job_id,
|
|
analysis_type="detection",
|
|
status="running",
|
|
model_name=model.model_id,
|
|
model_version=model.version,
|
|
parameters_json=parameters,
|
|
started_at=DetectionService._now(),
|
|
)
|
|
db.add(analysis_run)
|
|
db.commit()
|
|
db.refresh(analysis_run)
|
|
return analysis_run
|
|
|
|
@staticmethod
|
|
def _mark_failed(db, analysis_run: AnalysisRun, job: Job, code: str, message: str) -> None:
|
|
result = {"error_code": code, "message": message, "detection_count": 0}
|
|
analysis_run.status = "failed"
|
|
analysis_run.finished_at = DetectionService._now()
|
|
analysis_run.error_message = message
|
|
analysis_run.result_json = result
|
|
job.status = "failed"
|
|
job.finished_at = analysis_run.finished_at
|
|
job.error_message = message
|
|
job.result_json = result
|
|
db.add(analysis_run)
|
|
db.add(job)
|
|
db.commit()
|
|
db.refresh(analysis_run)
|
|
db.refresh(job)
|
|
|
|
@staticmethod
|
|
def _mark_success(db, analysis_run: AnalysisRun, job: Job, detection_count: int, extra_result: dict[str, Any] | None = None) -> None:
|
|
result = {"detection_count": detection_count}
|
|
if extra_result:
|
|
result.update(extra_result)
|
|
analysis_run.status = "success"
|
|
analysis_run.finished_at = DetectionService._now()
|
|
analysis_run.result_json = result
|
|
job.status = "success"
|
|
job.finished_at = analysis_run.finished_at
|
|
job.result_json = result
|
|
db.add(analysis_run)
|
|
db.add(job)
|
|
db.commit()
|
|
db.refresh(analysis_run)
|
|
db.refresh(job)
|
|
|
|
@staticmethod
|
|
def _persist_fixture_detections(
|
|
db,
|
|
project_id: uuid.UUID,
|
|
dataset_id: uuid.UUID,
|
|
analysis_run: AnalysisRun,
|
|
job: Job,
|
|
model_name: str,
|
|
model_version: str | None,
|
|
raw_detections: Any,
|
|
confidence_threshold: float,
|
|
class_filter: list[str],
|
|
) -> list[Detection]:
|
|
if not isinstance(raw_detections, list):
|
|
raise AppError(code="INVALID_FIXTURE_DETECTIONS", message="fixture_detections must be a list", status_code=400)
|
|
persisted: list[Detection] = []
|
|
allowed_classes = set(class_filter)
|
|
for raw in raw_detections:
|
|
if not isinstance(raw, dict):
|
|
raise AppError(code="INVALID_FIXTURE_DETECTION", message="Each fixture detection must be an object", status_code=400)
|
|
class_name = str(raw.get("class_name") or "")
|
|
confidence = float(raw.get("confidence", 0.0))
|
|
if allowed_classes and class_name not in allowed_classes:
|
|
continue
|
|
if confidence < confidence_threshold:
|
|
continue
|
|
geometry_payload = raw.get("geometry")
|
|
if not isinstance(geometry_payload, dict):
|
|
raise AppError(code="INVALID_FIXTURE_DETECTION", message="Fixture detection geometry is required", status_code=400)
|
|
geometry = shape(geometry_payload)
|
|
if geometry.is_empty or not geometry.is_valid:
|
|
raise AppError(code="INVALID_FIXTURE_GEOMETRY", message="Fixture detection geometry must be valid", status_code=400)
|
|
detection = Detection(
|
|
id=uuid.uuid4(),
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
analysis_run_id=analysis_run.id,
|
|
job_id=job.id,
|
|
model_name=model_name,
|
|
model_version=model_version,
|
|
class_name=class_name,
|
|
confidence=confidence,
|
|
geometry=from_shape(geometry, srid=4326),
|
|
bbox_json=raw.get("bbox_json"),
|
|
source_tile_path=raw.get("source_tile_path"),
|
|
properties_json=raw.get("properties_json"),
|
|
)
|
|
db.add(detection)
|
|
persisted.append(detection)
|
|
db.commit()
|
|
for detection in persisted:
|
|
db.refresh(detection)
|
|
return persisted
|
|
|
|
@staticmethod
|
|
def _run_configured_yolo(
|
|
db,
|
|
project_id: uuid.UUID,
|
|
dataset_id: uuid.UUID,
|
|
analysis_run: AnalysisRun,
|
|
job: Job,
|
|
model_name: str,
|
|
model_version: str | None,
|
|
tile_manifest_path: str | None,
|
|
confidence_threshold: float,
|
|
class_filter: list[str],
|
|
settings: Settings,
|
|
yolo_adapter_class: Type[YoloDetectionAdapter],
|
|
) -> tuple[list[Detection], dict[str, Any]]:
|
|
manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles, settings)
|
|
dataset = db.get(Dataset, dataset_id)
|
|
if dataset is None:
|
|
raise AppError(code="DATASET_NOT_FOUND", message="Dataset not found", status_code=404)
|
|
manifest_binding = TileManifestService.validate_for_inference(
|
|
db,
|
|
dataset,
|
|
manifest,
|
|
manifest_path=tile_manifest_path or "",
|
|
settings=settings,
|
|
error_prefix="DETECTION",
|
|
)
|
|
DetectionService._attach_tile_manifest_binding(analysis_run, job, manifest_binding)
|
|
model_path = Path(settings.yolo_model_path or "").expanduser()
|
|
runtime_model_provenance = RuntimeModelProvenanceService.validate_for_production_runtime(
|
|
db=db,
|
|
model_path=model_path,
|
|
model_id=model_name,
|
|
task_type="object_detection",
|
|
expected_model_version=model_version,
|
|
allowed_frameworks=("ultralytics/pytorch", "ultralytics", "pytorch"),
|
|
)
|
|
DetectionService._attach_runtime_model_provenance(
|
|
analysis_run,
|
|
job,
|
|
runtime_model_provenance,
|
|
)
|
|
adapter = yolo_adapter_class(settings)
|
|
model = adapter.load_model(model_path)
|
|
allowed_classes = {DetectionService._canonical_class_name(value) for value in class_filter if DetectionService._canonical_class_name(value)}
|
|
candidates: list[dict[str, Any]] = []
|
|
manifest_crs = DetectionService._require_manifest_crs(manifest)
|
|
raster_bounds = DetectionService._bounds_to_epsg4326(manifest.get("bounds"), manifest_crs)
|
|
tiles = list(manifest["tiles"])
|
|
tile_paths = [
|
|
DetectionService._resolve_tile_path(tile, Path(tile_manifest_path or "").expanduser(), settings) for tile in tiles
|
|
]
|
|
# Batched so the GPU is not idle between tiles; each tile keeps its own
|
|
# transform for georeferencing, so results stay per tile and in order.
|
|
detections_per_tile = adapter.predict_tiles(model, tile_paths, confidence_threshold)
|
|
for tile, tile_path, raw_detections in zip(tiles, tile_paths, detections_per_tile):
|
|
tile_crs = tile.get("crs") or manifest_crs
|
|
tile_bounds_4326 = DetectionService._bounds_to_epsg4326(tile.get("bounds"), tile_crs)
|
|
tile_edge_tolerance = DetectionService._tile_edge_tolerance(tile, tile_bounds_4326)
|
|
for raw in raw_detections:
|
|
model_class_name = str(raw.get("class_name") or "").strip()
|
|
class_name = DetectionService._canonical_class_name(model_class_name)
|
|
confidence = float(raw.get("confidence", 0.0))
|
|
if allowed_classes and class_name not in allowed_classes:
|
|
continue
|
|
if confidence < confidence_threshold:
|
|
continue
|
|
bbox = raw.get("bbox")
|
|
if not isinstance(bbox, list):
|
|
raise AppError(code="DETECTION_INVALID_BBOX", message="YOLO adapter returned a detection without bbox", status_code=422)
|
|
geometry = pixel_bbox_to_epsg4326_polygon(bbox=bbox, tile=tile, crs=tile_crs)
|
|
properties = dict(raw.get("properties") or {})
|
|
if model_class_name and model_class_name != class_name:
|
|
properties.setdefault("model_class_name", model_class_name)
|
|
candidates.append(
|
|
{
|
|
"class_name": class_name,
|
|
"confidence": confidence,
|
|
"geometry": geometry,
|
|
"bbox": bbox,
|
|
"source_tile_path": str(tile_path),
|
|
"properties": {**properties, "tile_index": tile.get("index")},
|
|
"tile_bounds": tile_bounds_4326,
|
|
"tile_edge_tolerance": tile_edge_tolerance,
|
|
}
|
|
)
|
|
edge_filtered_candidates = candidates
|
|
if settings.yolo_suppress_tile_edge_detections:
|
|
edge_filtered_candidates = DetectionService._drop_tile_edge_truncations(
|
|
candidates,
|
|
raster_bounds=raster_bounds,
|
|
tolerance=0.0,
|
|
)
|
|
filtered_candidates = DetectionService._suppress_duplicate_candidates(
|
|
edge_filtered_candidates,
|
|
iou_threshold=float(settings.yolo_duplicate_iou_threshold),
|
|
containment_threshold=float(settings.yolo_containment_nms_threshold),
|
|
)
|
|
persisted: list[Detection] = []
|
|
for candidate in filtered_candidates:
|
|
bbox = candidate["bbox"]
|
|
detection = Detection(
|
|
id=uuid.uuid4(),
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
analysis_run_id=analysis_run.id,
|
|
job_id=job.id,
|
|
model_name=model_name,
|
|
model_version=model_version,
|
|
class_name=candidate["class_name"],
|
|
confidence=candidate["confidence"],
|
|
geometry=from_shape(candidate["geometry"], srid=4326),
|
|
bbox_json={
|
|
"x_min": float(bbox[0]),
|
|
"y_min": float(bbox[1]),
|
|
"x_max": float(bbox[2]),
|
|
"y_max": float(bbox[3]),
|
|
},
|
|
source_tile_path=candidate["source_tile_path"],
|
|
properties_json={
|
|
**candidate["properties"],
|
|
"runtime_model_provenance": runtime_model_provenance.as_dict(),
|
|
},
|
|
)
|
|
db.add(detection)
|
|
persisted.append(detection)
|
|
db.commit()
|
|
for detection in persisted:
|
|
db.refresh(detection)
|
|
return persisted, {
|
|
"raw_detection_count": len(candidates),
|
|
"suppressed_detection_count": len(candidates) - len(filtered_candidates),
|
|
"tile_edge_truncated_count": len(candidates) - len(edge_filtered_candidates),
|
|
"duplicate_iou_threshold": float(settings.yolo_duplicate_iou_threshold),
|
|
"containment_suppression_threshold": float(settings.yolo_containment_nms_threshold),
|
|
"tile_manifest_binding": manifest_binding,
|
|
"runtime_model_provenance": runtime_model_provenance.as_dict(),
|
|
}
|
|
|
|
@staticmethod
|
|
def _attach_tile_manifest_binding(
|
|
analysis_run: AnalysisRun,
|
|
job: Job,
|
|
binding: dict[str, Any],
|
|
) -> None:
|
|
analysis_parameters = dict(analysis_run.parameters_json or {})
|
|
analysis_parameters["tile_manifest_binding"] = dict(binding)
|
|
analysis_run.parameters_json = analysis_parameters
|
|
job_parameters = dict(job.parameters_json or {})
|
|
job_parameters["tile_manifest_binding"] = dict(binding)
|
|
job.parameters_json = job_parameters
|
|
|
|
@staticmethod
|
|
def _attach_runtime_model_provenance(
|
|
analysis_run: AnalysisRun,
|
|
job: Job,
|
|
provenance: RuntimeModelProvenance,
|
|
) -> None:
|
|
"""Persist byte-bound model evidence with the run before adapter loading.
|
|
|
|
Individual detections retain the same evidence in ``properties_json``;
|
|
this run-level copy is the compact audit root for a complete inference.
|
|
Assigning fresh dictionaries matters for SQLAlchemy JSON change tracking.
|
|
"""
|
|
|
|
evidence = provenance.as_dict()
|
|
analysis_parameters = dict(analysis_run.parameters_json or {})
|
|
analysis_parameters["runtime_model_provenance"] = evidence
|
|
analysis_run.parameters_json = analysis_parameters
|
|
job_parameters = dict(job.parameters_json or {})
|
|
job_parameters["runtime_model_provenance"] = evidence
|
|
job.parameters_json = job_parameters
|
|
|
|
@staticmethod
|
|
def _canonical_class_name(value: Any) -> str:
|
|
return str(value or "").strip().casefold()
|
|
|
|
# An object wider than the tile overlap is truncated by both tiles, so the
|
|
# two halves barely intersect and IoU alone never suppresses them. Overlap
|
|
# measured against the smaller box catches that case; the threshold is
|
|
# deliberately strict so that terraced houses stay separate detections.
|
|
# Fallback only. The served value is configuration, so a promoted model can
|
|
# be run at the threshold its evaluation froze.
|
|
CONTAINMENT_SUPPRESSION_THRESHOLD = 0.85
|
|
|
|
@staticmethod
|
|
def _suppress_duplicate_candidates(
|
|
candidates: list[dict[str, Any]],
|
|
iou_threshold: float,
|
|
containment_threshold: float | None = None,
|
|
) -> list[dict[str, Any]]:
|
|
if iou_threshold <= 0 or len(candidates) < 2:
|
|
return candidates
|
|
if containment_threshold is None:
|
|
containment_threshold = DetectionService.CONTAINMENT_SUPPRESSION_THRESHOLD
|
|
|
|
ordered = sorted(
|
|
candidates,
|
|
key=lambda item: (-float(item["confidence"]), str(item.get("source_tile_path") or "")),
|
|
)
|
|
kept: list[dict[str, Any]] = []
|
|
kept_geometries: list[Any] = []
|
|
tree = None
|
|
|
|
for candidate in ordered:
|
|
geometry = candidate["geometry"]
|
|
duplicate = False
|
|
# Only geometries that actually touch this candidate can suppress
|
|
# it, so an index keeps a dense AOI from turning into an O(n^2) scan.
|
|
neighbour_indexes = range(len(kept)) if tree is None else (int(index) for index in tree.query(geometry))
|
|
for index in neighbour_indexes:
|
|
kept_candidate = kept[index]
|
|
if candidate["class_name"] != kept_candidate["class_name"]:
|
|
continue
|
|
other = kept_geometries[index]
|
|
if DetectionService._geometry_iou(geometry, other) >= iou_threshold:
|
|
duplicate = True
|
|
break
|
|
if DetectionService._geometry_containment(geometry, other) >= containment_threshold:
|
|
duplicate = True
|
|
break
|
|
if not duplicate:
|
|
kept.append(candidate)
|
|
kept_geometries.append(geometry)
|
|
tree = STRtree(kept_geometries)
|
|
return kept
|
|
|
|
@staticmethod
|
|
def _drop_tile_edge_truncations(
|
|
candidates: list[dict[str, Any]],
|
|
*,
|
|
raster_bounds: tuple[float, float, float, float] | None,
|
|
tolerance: float,
|
|
) -> list[dict[str, Any]]:
|
|
"""Discard boxes cut off by an interior tile edge.
|
|
|
|
Such a box describes only the part of the object that fell inside its
|
|
tile. Because tiles overlap, the neighbouring tile saw the object whole
|
|
and contributed the box worth keeping. A box against the outer raster
|
|
edge has no such neighbour and is kept.
|
|
"""
|
|
|
|
if raster_bounds is None or tolerance <= 0:
|
|
return candidates
|
|
|
|
raster_left, raster_bottom, raster_right, raster_top = raster_bounds
|
|
kept: list[dict[str, Any]] = []
|
|
for candidate in candidates:
|
|
tile_bounds = candidate.get("tile_bounds")
|
|
if not tile_bounds or len(tuple(tile_bounds)) != 4:
|
|
kept.append(candidate)
|
|
continue
|
|
tile_left, tile_bottom, tile_right, tile_top = (float(value) for value in tile_bounds)
|
|
left, bottom, right, top = candidate["geometry"].bounds
|
|
# A pixel-sized tolerance per tile: a fixed degree value would be
|
|
# wrong for both a 10 cm orthophoto and a coarse thematic raster.
|
|
tolerance = float(candidate.get("tile_edge_tolerance") or 0.0) or tolerance
|
|
|
|
touches_interior_edge = (
|
|
(abs(left - tile_left) <= tolerance and abs(tile_left - raster_left) > tolerance)
|
|
or (abs(right - tile_right) <= tolerance and abs(tile_right - raster_right) > tolerance)
|
|
or (abs(bottom - tile_bottom) <= tolerance and abs(tile_bottom - raster_bottom) > tolerance)
|
|
or (abs(top - tile_top) <= tolerance and abs(tile_top - raster_top) > tolerance)
|
|
)
|
|
if not touches_interior_edge:
|
|
kept.append(candidate)
|
|
return kept
|
|
|
|
@staticmethod
|
|
def _geometry_iou(left, right) -> float:
|
|
if left.is_empty or right.is_empty:
|
|
return 0.0
|
|
intersection_area = left.intersection(right).area
|
|
if intersection_area <= 0:
|
|
return 0.0
|
|
union_area = left.union(right).area
|
|
if union_area <= 0:
|
|
return 0.0
|
|
return intersection_area / union_area
|
|
|
|
@staticmethod
|
|
def _geometry_containment(left, right) -> float:
|
|
"""Intersection over the smaller of the two areas."""
|
|
|
|
if left.is_empty or right.is_empty:
|
|
return 0.0
|
|
smaller_area = min(left.area, right.area)
|
|
if smaller_area <= 0:
|
|
return 0.0
|
|
intersection_area = left.intersection(right).area
|
|
if intersection_area <= 0:
|
|
return 0.0
|
|
return intersection_area / smaller_area
|
|
|
|
@staticmethod
|
|
def _require_manifest_crs(manifest: dict[str, Any]) -> str:
|
|
"""Refuse to georeference inference output against a guessed CRS.
|
|
|
|
Detection QA already rejects a tile without explicit CRS metadata.
|
|
Silently assuming EPSG:4326 on the inference side produced geometry
|
|
that looks plausible on a map but sits in the wrong place.
|
|
"""
|
|
|
|
raw_crs = manifest.get("crs") or manifest.get("source_crs") or manifest.get("dataset_crs")
|
|
if not isinstance(raw_crs, str) or not raw_crs.strip():
|
|
raise AppError(
|
|
code="DETECTION_TILE_MANIFEST_INVALID",
|
|
message="Raster tile manifest requires explicit CRS metadata for georeferencing",
|
|
status_code=422,
|
|
)
|
|
return raw_crs.strip()
|
|
|
|
@staticmethod
|
|
def _bounds_to_epsg4326(bounds: Any, crs: str | None) -> tuple[float, float, float, float] | None:
|
|
if not isinstance(bounds, (list, tuple)) or len(bounds) != 4:
|
|
return None
|
|
try:
|
|
left, bottom, right, top = (float(value) for value in bounds)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
if left >= right or bottom >= top:
|
|
return None
|
|
if not crs or str(crs).strip().upper() in {"EPSG:4326", "4326"}:
|
|
return (left, bottom, right, top)
|
|
try:
|
|
transformer = Transformer.from_crs(crs, "EPSG:4326", always_xy=True)
|
|
# Transform the whole rectangle, not just two corners: a projected
|
|
# box does not stay axis-aligned after reprojection.
|
|
projected = shapely_transform(transformer.transform, shapely_box(left, bottom, right, top))
|
|
return projected.bounds
|
|
except Exception:
|
|
return None
|
|
|
|
@staticmethod
|
|
def _tile_edge_tolerance(tile: dict[str, Any], tile_bounds_4326: tuple[float, float, float, float] | None) -> float:
|
|
"""One and a half pixels, expressed in the degrees the boxes live in."""
|
|
|
|
if tile_bounds_4326 is None:
|
|
return 0.0
|
|
pixel_window = tile.get("pixel_window")
|
|
if not (isinstance(pixel_window, (list, tuple)) and len(pixel_window) == 4):
|
|
return 0.0
|
|
try:
|
|
width = float(pixel_window[2])
|
|
height = float(pixel_window[3])
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
if width <= 0 or height <= 0:
|
|
return 0.0
|
|
left, bottom, right, top = tile_bounds_4326
|
|
return 1.5 * max((right - left) / width, (top - bottom) / height)
|
|
|
|
@staticmethod
|
|
def _load_tile_manifest(tile_manifest_path: str | None, max_tiles: int, settings: Settings | None = None) -> dict[str, Any]:
|
|
if not tile_manifest_path:
|
|
raise AppError(
|
|
code="DETECTION_TILE_MANIFEST_REQUIRED",
|
|
message="Configured YOLO inference requires an existing raster tile manifest path",
|
|
status_code=400,
|
|
)
|
|
# The path arrives in the request, so it must name a governed artifact
|
|
# rather than an arbitrary file on the host.
|
|
manifest_path = StorageService.assert_within_storage_root(
|
|
tile_manifest_path, label="tile manifest", settings=settings
|
|
)
|
|
if not manifest_path.exists() or not manifest_path.is_file():
|
|
raise AppError(
|
|
code="DETECTION_TILE_MANIFEST_NOT_FOUND",
|
|
message="Raster tile manifest path does not exist",
|
|
details={"tile_manifest_path": str(manifest_path)},
|
|
status_code=422,
|
|
)
|
|
try:
|
|
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
except json.JSONDecodeError as exc:
|
|
raise AppError(code="DETECTION_TILE_MANIFEST_INVALID", message="Raster tile manifest must be valid JSON", status_code=422) from exc
|
|
tiles = manifest.get("tiles")
|
|
if not isinstance(tiles, list) or not tiles:
|
|
raise AppError(code="DETECTION_TILE_MANIFEST_INVALID", message="Raster tile manifest must contain tiles", status_code=422)
|
|
if len(tiles) > max_tiles:
|
|
raise AppError(
|
|
code="DETECTION_TILE_LIMIT_EXCEEDED",
|
|
message="Raster tile manifest exceeds configured YOLO tile limit",
|
|
details={"tile_count": len(tiles), "max_tiles": max_tiles},
|
|
status_code=422,
|
|
)
|
|
return manifest
|
|
|
|
@staticmethod
|
|
def _resolve_tile_path(tile: dict[str, Any], manifest_path: Path, settings: Settings | None = None) -> Path:
|
|
raw_path = tile.get("path")
|
|
if not isinstance(raw_path, str) or not raw_path:
|
|
raise AppError(code="DETECTION_TILE_MANIFEST_INVALID", message="Tile manifest entries require a path", status_code=422)
|
|
tile_path = Path(raw_path).expanduser()
|
|
if not tile_path.is_absolute():
|
|
tile_path = manifest_path.parent / tile_path
|
|
# A manifest entry may name an absolute path; it is still only allowed
|
|
# to point at a tile the runtime itself produced.
|
|
tile_path = StorageService.assert_within_storage_root(tile_path, label="raster tile", settings=settings)
|
|
if not tile_path.exists() or not tile_path.is_file():
|
|
raise AppError(
|
|
code="DETECTION_TILE_NOT_FOUND",
|
|
message="Tile referenced by manifest does not exist",
|
|
details={"tile_path": str(tile_path)},
|
|
status_code=422,
|
|
)
|
|
return tile_path
|