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 shapely.geometry import mapping, shape 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_qa_service import DetectionQaService from app.services.model_asset_catalog_service import ModelAssetCatalogService from app.services.model_registry_service import ModelRegistryService from app.services.qa_service import QaService from app.services.quality_service import QualityService from app.services.temporal_compatibility_service import TemporalCompatibilityService 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, ) -> DetectionRunResponse: parameters = dict(parameters_json or {}) resolved_settings = settings or get_settings() 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, ) 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) 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) 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: allowed_names = [value.strip().casefold() for value in settings.yolo_validated_area_names.split(",") if value.strip()] area = db.get(Area, dataset.area_id) if dataset.area_id else None area_name = area.name.strip() if area is not None else "" if not area_name or not any(token in area_name.casefold() for token in allowed_names): raise AppError( code="DETECTION_VALIDATION_SCOPE_UNAVAILABLE", message="Configured YOLO inference is not validated for this Dataset area.", details={"dataset_id": str(dataset.id), "dataset_area": area_name or None, "validated_area_names": allowed_names}, status_code=422, ) @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, ) -> 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() return DetectionRunListResponse(items=[DetectionRunRead.model_validate(row) for row in rows], total=len(rows)) @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, ) -> 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, ) items = [DetectionRead.model_validate(row) for row in rows] return DetectionListResponse(items=items, total=len(items)) @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, ) -> dict[str, Any]: detections = DetectionService._query_detection_rows( db, analysis_run_id=analysis_run_id, dataset_id=dataset_id, class_name=class_name, min_confidence=min_confidence, ) return { "type": "FeatureCollection", "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, ) -> 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, ) 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}, to_shape(row.geometry)) for row in detections] candidate_geometries = raw_candidate_geometries 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, ) coverage = None if manifest_path: manifest = DetectionService._load_tile_manifest(manifest_path, resolved_settings.yolo_max_tiles) 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, ) box_to_footprint_diagnostics = DetectionQaService.box_to_footprint_diagnostics( evidence, envelope_evidence, iou_threshold=iou_threshold, ) 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, "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, }, ) 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, "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]) -> 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 _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) return query.order_by(Detection.created_at.desc()).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) model_path = Path(settings.yolo_model_path or "").expanduser() 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 = manifest.get("crs") or manifest.get("source_crs") or manifest.get("dataset_crs") or "EPSG:4326" for tile in manifest["tiles"]: tile_path = DetectionService._resolve_tile_path(tile, Path(tile_manifest_path or "").expanduser()) for raw in adapter.predict_tile(model, tile_path, confidence_threshold): 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.get("crs") or manifest_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")}, } ) filtered_candidates = DetectionService._suppress_duplicate_candidates( candidates, iou_threshold=float(settings.yolo_duplicate_iou_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"], ) 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), "duplicate_iou_threshold": float(settings.yolo_duplicate_iou_threshold), } @staticmethod def _canonical_class_name(value: Any) -> str: return str(value or "").strip().casefold() @staticmethod def _suppress_duplicate_candidates(candidates: list[dict[str, Any]], iou_threshold: float) -> list[dict[str, Any]]: if iou_threshold <= 0 or len(candidates) < 2: return candidates kept: list[dict[str, Any]] = [] for candidate in sorted(candidates, key=lambda item: float(item["confidence"]), reverse=True): duplicate = False for kept_candidate in kept: if candidate["class_name"] != kept_candidate["class_name"]: continue if DetectionService._geometry_iou(candidate["geometry"], kept_candidate["geometry"]) >= iou_threshold: duplicate = True break if not duplicate: 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 _load_tile_manifest(tile_manifest_path: str | None, max_tiles: int) -> 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, ) manifest_path = Path(tile_manifest_path).expanduser() 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) -> 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 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