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