/detection/runs/{id}/detections and its GeoJSON sibling returned every
persisted detection, as did the segmentation equivalents. A regional run holds
tens of thousands, and these are the endpoints the results table and the map
overlay call after every run.
They now take limit and offset, default to 2.000, and report total, limit,
offset and truncated so the complete population stays visible while what is
transferred does not. The GeoJSON responses carry the same window in a
geointel_result_window foreign member.
Rows are ordered by confidence, so a capped overlay draws the strongest
detections rather than an arbitrary slice, and the lab says how many of how
many are being shown rather than silently presenting a page as the whole run.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1298 lines
58 KiB
Python
1298 lines
58 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
import json
|
|
import logging
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from typing import Type
|
|
|
|
from geoalchemy2.shape import from_shape, to_shape
|
|
from pyproj import Transformer
|
|
from shapely.geometry import box as shapely_box
|
|
from shapely.geometry import mapping, shape
|
|
from shapely.ops import transform as shapely_transform
|
|
from shapely.strtree import STRtree
|
|
from sqlalchemy import func
|
|
|
|
from app.core.config import Settings, get_settings
|
|
from app.core.errors import AppError
|
|
from app.core.request_context import get_request_id
|
|
from app.models import AnalysisRun, Area, Dataset, Detection, Job, Project, VectorFeature
|
|
from app.schemas.detection import DetectionListResponse, DetectionRead, DetectionRunListResponse, DetectionRunRead, DetectionRunResponse
|
|
from app.services.detection_georeferencing import pixel_bbox_to_epsg4326_polygon
|
|
from app.services.detection_metrics_service import DetectionMetricsService
|
|
from app.services.detection_qa_service import DetectionQaService
|
|
from app.services.dataset_consumption_gate_service import DatasetConsumptionGate
|
|
from app.services.model_asset_catalog_service import ModelAssetCatalogService
|
|
from app.services.model_registry_service import ModelRegistryService
|
|
from app.services.model_validation_scope_service import ModelValidationScopeService
|
|
from app.services.qa_service import QaService
|
|
from app.services.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.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,
|
|
) -> 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,
|
|
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,
|
|
) -> 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)
|
|
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,
|
|
)
|
|
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,
|
|
"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,
|
|
"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
|
|
|
|
@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)
|
|
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()) 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),
|
|
)
|
|
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": DetectionService.CONTAINMENT_SUPPRESSION_THRESHOLD,
|
|
"runtime_model_provenance": runtime_model_provenance.as_dict(),
|
|
}
|
|
|
|
@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.
|
|
CONTAINMENT_SUPPRESSION_THRESHOLD = 0.85
|
|
|
|
@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
|
|
|
|
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)
|
|
>= DetectionService.CONTAINMENT_SUPPRESSION_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) -> 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
|