619 lines
26 KiB
Python
619 lines
26 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 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.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,
|
|
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,
|
|
)
|
|
|
|
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,
|
|
"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 = 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))
|
|
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,
|
|
)
|
|
references = db.query(VectorFeature).filter(VectorFeature.dataset_id == reference_dataset_id).all()
|
|
if not references:
|
|
raise AppError(
|
|
code="REFERENCE_FEATURES_NOT_FOUND",
|
|
message="Reference dataset has no persisted vector features for QA",
|
|
status_code=422,
|
|
)
|
|
|
|
candidate_geometries = [({"id": str(row.id), "class_name": row.class_name}, to_shape(row.geometry)) for row in detections]
|
|
reference_geometries = [({"id": str(row.id), "feature_class": row.feature_class}, to_shape(row.geometry)) for row in references]
|
|
matches, false_positives, false_negatives, match_iou_values, warnings, unsupported = QaService._match_io_u_metrics(
|
|
candidate_geometries,
|
|
reference_geometries,
|
|
iou_threshold,
|
|
)
|
|
mean_iou = None if not match_iou_values else sum(match_iou_values) / len(match_iou_values)
|
|
precision = matches / (matches + false_positives) if matches + false_positives > 0 else None
|
|
recall = matches / (matches + false_negatives) if matches + 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 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,
|
|
},
|
|
findings={
|
|
"matches": matches,
|
|
"false_positives": false_positives,
|
|
"false_negatives": false_negatives,
|
|
"warnings": warnings,
|
|
"unsupported_geometry": unsupported,
|
|
},
|
|
metrics={
|
|
"precision": precision,
|
|
"recall": recall,
|
|
"f1": f1_score,
|
|
"mean_iou": mean_iou,
|
|
"false_positive_count": false_positives,
|
|
"false_negative_count": 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),
|
|
"matches": matches,
|
|
"false_positives": false_positives,
|
|
"false_negatives": false_negatives,
|
|
"precision": precision,
|
|
"recall": recall,
|
|
"f1_score": f1_score,
|
|
"mean_iou": mean_iou,
|
|
"iou_threshold": iou_threshold,
|
|
"warnings": warnings,
|
|
}
|
|
|
|
@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) -> None:
|
|
result = {"detection_count": detection_count}
|
|
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],
|
|
) -> list[Detection]:
|
|
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 = set(class_filter)
|
|
persisted: list[Detection] = []
|
|
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):
|
|
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
|
|
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)
|
|
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={
|
|
"x_min": float(bbox[0]),
|
|
"y_min": float(bbox[1]),
|
|
"x_max": float(bbox[2]),
|
|
"y_max": float(bbox[3]),
|
|
},
|
|
source_tile_path=str(tile_path),
|
|
properties_json={**dict(raw.get("properties") or {}), "tile_index": tile.get("index")},
|
|
)
|
|
db.add(detection)
|
|
persisted.append(detection)
|
|
db.commit()
|
|
for detection in persisted:
|
|
db.refresh(detection)
|
|
return persisted
|
|
|
|
@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
|