Files
geointel/backend/app/services/detection_service.py
T
Codex 0cad8fdf76
GeoIntel CI / docs-smoke (push) Canceled after 0s
GeoIntel CI / contract-smoke (push) Canceled after 0s
fix: bound detection QA to inference coverage
2026-07-15 00:35:39 +02:00

798 lines
36 KiB
Python

from __future__ import annotations
import uuid
import json
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.models import AnalysisRun, 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.yolo_adapter import YoloDetectionAdapter
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,
)
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,
)
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)
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":
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 [],
)
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,
)
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.",
)
raise AppError(code="DETECTION_MODEL_UNAVAILABLE", message="Detection model is unavailable", status_code=503)
@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)
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"],
},
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,
"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,
},
)
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,
"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