Tile handling produced results that were wrong before any model quality
question arose:
- orthophoto tiles reached the model through PIL convert("RGB"), which
truncates the high byte of a 16-bit product and treats a 4-band RGB+NIR
tile's infrared channel as colour. Tiles are now read with rasterio, the
visible bands are chosen explicitly, and values are percentile-stretched
across all three bands together so hue is preserved;
- an object wider than the tile overlap was truncated by both tiles into two
boxes that barely intersect, so IoU suppression kept both: two false
positives and one missed footprint per seam building. Suppression now also
compares overlap against the smaller box, and boxes cut by an interior tile
edge are dropped in favour of the neighbouring tile's complete view;
- georeferencing fell back to an assumed EPSG:4326 when a manifest carried no
CRS, producing geometry that renders plausibly in the wrong place. QA
already refused such a tile; inference now fails closed too.
Segmentation QA scored candidates against every reference feature in the
dataset, so every building outside the inferred tiles counted as a false
negative. It now applies the same persisted tile coverage that detection QA
has always used, including the indexed ST_Intersects prefilter.
Duplicate suppression uses an STRtree instead of the O(n^2) scan, tiles are
predicted in batches of YOLO_BATCH_SIZE (a setting that existed but was never
read), and detection/segmentation runs can be queued through /run-async for a
polling background worker rather than holding an HTTP worker thread for
minutes of GPU work.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
965 lines
43 KiB
Python
965 lines
43 KiB
Python
from __future__ import annotations
|
|
|
|
import uuid
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from geoalchemy2.shape import from_shape, to_shape
|
|
from shapely.geometry import MultiPolygon, Polygon, mapping, shape
|
|
from shapely.validation import make_valid
|
|
from sqlalchemy import func
|
|
|
|
from app.core.config import Settings, get_settings
|
|
from app.core.errors import AppError
|
|
from app.models import AnalysisRun, Dataset, Job, Project, Segmentation, VectorFeature
|
|
from app.schemas.segmentation import (
|
|
SegmentationListResponse,
|
|
SegmentationRead,
|
|
SegmentationRunListResponse,
|
|
SegmentationRunRead,
|
|
SegmentationRunResponse,
|
|
)
|
|
from app.services.detection_georeferencing import pixel_points_to_epsg4326_polygon
|
|
from app.services.detection_qa_service import DetectionQaService
|
|
from app.services.detection_service import DetectionService
|
|
from app.services.dataset_consumption_gate_service import DatasetConsumptionGate
|
|
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.runtime_model_provenance_service import RuntimeModelProvenance, RuntimeModelProvenanceService
|
|
from app.services.segmentation_adapter import (
|
|
FixtureSegmentationAdapter,
|
|
SamSegmentationAdapter,
|
|
YoloSegmentationAdapter,
|
|
)
|
|
|
|
|
|
class SegmentationService:
|
|
@staticmethod
|
|
def _now() -> datetime:
|
|
return datetime.now(UTC)
|
|
|
|
@staticmethod
|
|
def run_segmentation(
|
|
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_seg_adapter_class: type[YoloSegmentationAdapter] = YoloSegmentationAdapter,
|
|
sam_adapter_class: type[SamSegmentationAdapter] = SamSegmentationAdapter,
|
|
existing_job: Job | None = None,
|
|
) -> SegmentationRunResponse:
|
|
parameters = dict(parameters_json or {})
|
|
resolved_settings = settings or get_settings()
|
|
dataset = SegmentationService._validate_run_request(db, project_id=project_id, dataset_id=dataset_id)
|
|
|
|
model = ModelRegistryService.get_model_capability(
|
|
model_id,
|
|
settings=resolved_settings,
|
|
task_type="segmentation",
|
|
yolo_seg_adapter_class=yolo_seg_adapter_class,
|
|
sam_adapter_class=sam_adapter_class,
|
|
)
|
|
if model is None:
|
|
raise AppError(code="SEGMENTATION_MODEL_NOT_FOUND", message="Segmentation model not found", status_code=404)
|
|
if model.model_id == "fixture-segmenter" and parameters.get("fixture_mode") is not True:
|
|
raise AppError(
|
|
code="FIXTURE_MODE_REQUIRED",
|
|
message="Fixture segmenter requires explicit fixture_mode=true",
|
|
status_code=400,
|
|
)
|
|
configured_model_ids = {resolved_settings.yolo_seg_model_id, resolved_settings.sam_model_id}
|
|
if model.model_id in configured_model_ids and model.configured and not tile_manifest_path:
|
|
raise AppError(
|
|
code="SEGMENTATION_TILE_MANIFEST_REQUIRED",
|
|
message="Configured segmentation inference requires an existing raster tile manifest path",
|
|
status_code=400,
|
|
)
|
|
|
|
# Production segmentation must consume only a passed, complete and
|
|
# non-quarantined dataset. The fixture segmenter is QA/test-only and
|
|
# cannot be classified as production inference.
|
|
if model.model_id == "fixture-segmenter":
|
|
DatasetConsumptionGate.assert_eligible(
|
|
dataset,
|
|
purpose="quality_assessment",
|
|
fixture_mode=True,
|
|
)
|
|
elif model.model_id in configured_model_ids and model.configured:
|
|
DatasetConsumptionGate.assert_eligible(dataset, purpose="production_inference")
|
|
|
|
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 = SegmentationService._create_job(db, project_id, dataset_id, run_parameters, existing_job=existing_job)
|
|
analysis_run = SegmentationService._create_analysis_run(db, project_id, dataset_id, job.id, model, run_parameters)
|
|
|
|
if not model.configured:
|
|
message = model.limitation_message
|
|
SegmentationService._mark_failed(
|
|
db,
|
|
analysis_run,
|
|
job,
|
|
code="SEGMENTATION_MODEL_UNAVAILABLE",
|
|
message=message,
|
|
)
|
|
return SegmentationRunResponse(
|
|
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",
|
|
segmentation_count=0,
|
|
error_code="SEGMENTATION_MODEL_UNAVAILABLE",
|
|
message=message,
|
|
)
|
|
|
|
if model.model_id == "fixture-segmenter":
|
|
try:
|
|
segmentations = SegmentationService._persist_fixture_segmentations(
|
|
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_segmentations=parameters.get("fixture_segmentations"),
|
|
confidence_threshold=confidence_threshold,
|
|
class_filter=class_filter or [],
|
|
settings=resolved_settings,
|
|
)
|
|
except Exception as exc:
|
|
# A rejected fixture payload must never leave the run stuck in "running".
|
|
SegmentationService._fail_run_after_exception(db, analysis_run, job, exc, fallback_code="SEGMENTATION_INTERNAL_ERROR")
|
|
raise
|
|
SegmentationService._mark_success(db, analysis_run, job, segmentation_count=len(segmentations))
|
|
return SegmentationRunResponse(
|
|
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",
|
|
segmentation_count=len(segmentations),
|
|
message="Fixture segmentations persisted.",
|
|
)
|
|
|
|
if model.model_id in configured_model_ids:
|
|
try:
|
|
segmentations, postprocess_summary = SegmentationService._run_configured_segmentation(
|
|
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_seg_adapter_class=yolo_seg_adapter_class,
|
|
sam_adapter_class=sam_adapter_class,
|
|
)
|
|
except AppError as exc:
|
|
SegmentationService._mark_failed(db, analysis_run, job, code=exc.code, message=exc.message)
|
|
return SegmentationRunResponse(
|
|
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",
|
|
segmentation_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".
|
|
SegmentationService._fail_run_after_exception(db, analysis_run, job, exc, fallback_code="SEGMENTATION_INTERNAL_ERROR")
|
|
raise
|
|
SegmentationService._mark_success(
|
|
db,
|
|
analysis_run,
|
|
job,
|
|
segmentation_count=len(segmentations),
|
|
extra_result=postprocess_summary,
|
|
)
|
|
return SegmentationRunResponse(
|
|
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",
|
|
segmentation_count=len(segmentations),
|
|
message="Configured segmentation inference persisted georeferenced masks.",
|
|
)
|
|
|
|
SegmentationService._mark_failed(
|
|
db,
|
|
analysis_run,
|
|
job,
|
|
code="SEGMENTATION_MODEL_UNAVAILABLE",
|
|
message="Segmentation model is unavailable",
|
|
)
|
|
raise AppError(code="SEGMENTATION_MODEL_UNAVAILABLE", message="Segmentation model is unavailable", status_code=503)
|
|
|
|
@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:
|
|
SegmentationService._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) -> SegmentationRunRead:
|
|
run = db.get(AnalysisRun, analysis_run_id)
|
|
if not run or run.analysis_type != "segmentation":
|
|
raise AppError(code="SEGMENTATION_RUN_NOT_FOUND", message="Segmentation run not found", status_code=404)
|
|
return SegmentationRunRead.model_validate(run)
|
|
|
|
@staticmethod
|
|
def list_runs(
|
|
db,
|
|
*,
|
|
project_id: uuid.UUID | None = None,
|
|
dataset_id: uuid.UUID | None = None,
|
|
) -> SegmentationRunListResponse:
|
|
query = db.query(AnalysisRun).filter(AnalysisRun.analysis_type == "segmentation")
|
|
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 SegmentationRunListResponse(items=[SegmentationRunRead.model_validate(row) for row in rows], total=len(rows))
|
|
|
|
@staticmethod
|
|
def list_segmentations(
|
|
db,
|
|
analysis_run_id: uuid.UUID | None = None,
|
|
*,
|
|
dataset_id: uuid.UUID | None = None,
|
|
class_name: str | None = None,
|
|
min_confidence: float | None = None,
|
|
) -> SegmentationListResponse:
|
|
if analysis_run_id is not None:
|
|
run = db.get(AnalysisRun, analysis_run_id)
|
|
if not run or run.analysis_type != "segmentation":
|
|
raise AppError(code="SEGMENTATION_RUN_NOT_FOUND", message="Segmentation run not found", status_code=404)
|
|
rows = SegmentationService._query_segmentation_rows(
|
|
db,
|
|
analysis_run_id=analysis_run_id,
|
|
dataset_id=dataset_id,
|
|
class_name=class_name,
|
|
min_confidence=min_confidence,
|
|
)
|
|
items = [SegmentationRead.model_validate(row) for row in rows]
|
|
return SegmentationListResponse(items=items, total=len(items))
|
|
|
|
@staticmethod
|
|
def get_segmentation(db, segmentation_id: uuid.UUID) -> SegmentationRead:
|
|
segmentation = db.get(Segmentation, segmentation_id)
|
|
if not segmentation:
|
|
raise AppError(code="SEGMENTATION_NOT_FOUND", message="Segmentation not found", status_code=404)
|
|
return SegmentationRead.model_validate(segmentation)
|
|
|
|
@staticmethod
|
|
def segmentations_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]:
|
|
segmentations = SegmentationService._query_segmentation_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(segmentation.id),
|
|
"properties": SegmentationService._segmentation_properties(segmentation),
|
|
"geometry": mapping(to_shape(segmentation.geometry)),
|
|
}
|
|
for segmentation in segmentations
|
|
],
|
|
}
|
|
|
|
@staticmethod
|
|
def compare_segmentations_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 != "segmentation":
|
|
raise AppError(code="SEGMENTATION_RUN_NOT_FOUND", message="Segmentation 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 segmentation 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="Segmentation source dataset not found", status_code=404)
|
|
run_parameters = run.parameters_json if isinstance(run.parameters_json, dict) else {}
|
|
fixture_parameters = run_parameters.get("parameters_json")
|
|
fixture_mode = bool(
|
|
run.model_name == "fixture-segmenter"
|
|
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",
|
|
)
|
|
|
|
segmentations = SegmentationService._query_segmentation_rows(
|
|
db,
|
|
analysis_run_id=analysis_run_id,
|
|
dataset_id=run.dataset_id,
|
|
class_name=class_name,
|
|
min_confidence=min_confidence,
|
|
)
|
|
if not segmentations:
|
|
raise AppError(
|
|
code="SEGMENTATIONS_NOT_FOUND",
|
|
message="Segmentation run has no persisted geometries for QA",
|
|
status_code=422,
|
|
)
|
|
# Score against the footprint the model actually saw. Without this the
|
|
# whole reference dataset is the denominator for recall, and every
|
|
# building outside the inferred tiles is counted as a miss.
|
|
manifest_path = DetectionQaService.tile_manifest_path(run_parameters)
|
|
coverage = None
|
|
if manifest_path:
|
|
manifest = DetectionService._load_tile_manifest(manifest_path, get_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_candidate_geometries = [
|
|
(
|
|
{"id": str(row.id), "class_name": row.class_name, "confidence": row.confidence},
|
|
to_shape(row.geometry),
|
|
)
|
|
for row in segmentations
|
|
]
|
|
raw_reference_geometries = [
|
|
({"id": str(row.id), "feature_class": row.feature_class}, to_shape(row.geometry)) for row in references
|
|
]
|
|
candidate_geometries = raw_candidate_geometries
|
|
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,
|
|
)
|
|
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="segmentations_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,
|
|
"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,
|
|
"match_evidence": evidence.match_evidence,
|
|
"false_positive_evidence": evidence.false_positive_evidence,
|
|
"false_negative_evidence": evidence.false_negative_evidence,
|
|
}
|
|
|
|
@staticmethod
|
|
def mask_artifact_path(storage_root: str, project_id: uuid.UUID, analysis_run_id: uuid.UUID, tile_index: int | None, segmentation_id: uuid.UUID) -> str:
|
|
tile_folder = f"tile_{tile_index if tile_index is not None else 0}"
|
|
return (Path(storage_root) / "masks" / str(project_id) / str(analysis_run_id) / tile_folder / f"mask_{segmentation_id}.png").as_posix()
|
|
|
|
@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:
|
|
# Reuse the queued job so the operator polls one identifier.
|
|
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 = SegmentationService._now()
|
|
db.add(existing_job)
|
|
db.commit()
|
|
db.refresh(existing_job)
|
|
return existing_job
|
|
job = Job(
|
|
id=uuid.uuid4(),
|
|
job_type="segmentation.run",
|
|
status="running",
|
|
project_id=project_id,
|
|
dataset_id=dataset_id,
|
|
input_dataset_id=dataset_id,
|
|
parameters_json=parameters,
|
|
started_at=SegmentationService._now(),
|
|
)
|
|
db.add(job)
|
|
db.commit()
|
|
db.refresh(job)
|
|
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="Segmentation requires a raster dataset",
|
|
details={"dataset_type": dataset.dataset_type},
|
|
status_code=400,
|
|
)
|
|
return dataset
|
|
|
|
@staticmethod
|
|
def enqueue_segmentation(
|
|
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,
|
|
) -> Job:
|
|
"""Accept a segmentation run for background execution."""
|
|
|
|
SegmentationService._validate_run_request(db, project_id=project_id, dataset_id=dataset_id)
|
|
job = Job(
|
|
id=uuid.uuid4(),
|
|
job_type="segmentation.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,
|
|
"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)
|
|
return job
|
|
|
|
@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="segmentation",
|
|
status="running",
|
|
model_name=model.model_id,
|
|
model_version=model.version,
|
|
parameters_json=parameters,
|
|
started_at=SegmentationService._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, "segmentation_count": 0}
|
|
analysis_run.status = "failed"
|
|
analysis_run.finished_at = SegmentationService._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, segmentation_count: int, extra_result: dict[str, Any] | None = None) -> None:
|
|
result = {"segmentation_count": segmentation_count}
|
|
if extra_result:
|
|
result.update(extra_result)
|
|
analysis_run.status = "success"
|
|
analysis_run.finished_at = SegmentationService._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 _run_configured_segmentation(
|
|
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_seg_adapter_class: type[YoloSegmentationAdapter],
|
|
sam_adapter_class: type[SamSegmentationAdapter],
|
|
) -> tuple[list[Segmentation], dict[str, Any]]:
|
|
manifest = DetectionService._load_tile_manifest(tile_manifest_path, settings.yolo_max_tiles)
|
|
if model_name == settings.sam_model_id:
|
|
model_path = Path(settings.sam_model_path or "").expanduser()
|
|
allowed_frameworks = ("ultralytics/sam", "sam", "ultralytics", "pytorch")
|
|
adapter = sam_adapter_class(settings)
|
|
else:
|
|
model_path = Path(settings.yolo_seg_model_path or "").expanduser()
|
|
allowed_frameworks = ("ultralytics/pytorch", "ultralytics", "pytorch")
|
|
adapter = yolo_seg_adapter_class(settings)
|
|
runtime_model_provenance = RuntimeModelProvenanceService.validate_for_production_runtime(
|
|
db=db,
|
|
model_path=model_path,
|
|
model_id=model_name,
|
|
task_type="segmentation",
|
|
expected_model_version=model_version,
|
|
allowed_frameworks=allowed_frameworks,
|
|
)
|
|
SegmentationService._attach_runtime_model_provenance(
|
|
analysis_run,
|
|
job,
|
|
runtime_model_provenance,
|
|
)
|
|
model = adapter.load_model(model_path)
|
|
|
|
allowed_classes = {DetectionService._canonical_class_name(value) for value in class_filter if DetectionService._canonical_class_name(value)}
|
|
manifest_crs = DetectionService._require_manifest_crs(manifest)
|
|
candidates: list[dict[str, Any]] = []
|
|
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 = raw.get("confidence")
|
|
confidence = float(confidence) if confidence is not None else None
|
|
if allowed_classes and class_name not in allowed_classes:
|
|
continue
|
|
if confidence is not None and confidence < confidence_threshold:
|
|
continue
|
|
points = raw.get("points")
|
|
if not isinstance(points, list) or len(points) < 3:
|
|
continue
|
|
geometry = pixel_points_to_epsg4326_polygon(points=points, 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 if confidence is not None else 0.0,
|
|
"reported_confidence": confidence,
|
|
"geometry": geometry,
|
|
"bbox": raw.get("bbox"),
|
|
"source_tile_path": str(tile_path),
|
|
"tile_index": tile.get("index"),
|
|
"properties": {**properties, "tile_index": tile.get("index")},
|
|
}
|
|
)
|
|
filtered_candidates = DetectionService._suppress_duplicate_candidates(
|
|
candidates,
|
|
iou_threshold=float(settings.segmentation_duplicate_iou_threshold),
|
|
)
|
|
persisted: list[Segmentation] = []
|
|
for candidate in filtered_candidates:
|
|
geometry = candidate["geometry"]
|
|
if isinstance(geometry, Polygon):
|
|
geometry = MultiPolygon([geometry])
|
|
bbox = candidate.get("bbox")
|
|
bbox_json = None
|
|
if isinstance(bbox, list) and len(bbox) == 4:
|
|
bbox_json = {
|
|
"x_min": float(bbox[0]),
|
|
"y_min": float(bbox[1]),
|
|
"x_max": float(bbox[2]),
|
|
"y_max": float(bbox[3]),
|
|
}
|
|
segmentation = Segmentation(
|
|
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["reported_confidence"],
|
|
geometry=from_shape(geometry, srid=4326),
|
|
bbox_json=bbox_json,
|
|
area_m2=SegmentationService._geodesic_area_m2(geometry),
|
|
mask_path=None,
|
|
source_tile_path=candidate["source_tile_path"],
|
|
tile_index=candidate["tile_index"] if isinstance(candidate["tile_index"], int) else None,
|
|
properties_json=candidate["properties"],
|
|
provenance_json={
|
|
"inference": "local",
|
|
"model_id": model_name,
|
|
"tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()),
|
|
"tile_index": candidate["tile_index"],
|
|
"device": settings.yolo_device,
|
|
"runtime_model_provenance": runtime_model_provenance.as_dict(),
|
|
},
|
|
)
|
|
db.add(segmentation)
|
|
persisted.append(segmentation)
|
|
db.commit()
|
|
for segmentation in persisted:
|
|
db.refresh(segmentation)
|
|
return persisted, {
|
|
"raw_segmentation_count": len(candidates),
|
|
"suppressed_segmentation_count": len(candidates) - len(filtered_candidates),
|
|
"duplicate_iou_threshold": float(settings.segmentation_duplicate_iou_threshold),
|
|
"tile_manifest_path": str(Path(tile_manifest_path or "").expanduser()),
|
|
"runtime_model_provenance": runtime_model_provenance.as_dict(),
|
|
}
|
|
|
|
@staticmethod
|
|
def _attach_runtime_model_provenance(
|
|
analysis_run: AnalysisRun,
|
|
job: Job,
|
|
provenance: RuntimeModelProvenance,
|
|
) -> None:
|
|
"""Record immutable model evidence with a configured segmentation run."""
|
|
|
|
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 _geodesic_area_m2(geometry: MultiPolygon | Polygon) -> float | None:
|
|
try:
|
|
from pyproj import Geod
|
|
|
|
area, _ = Geod(ellps="WGS84").geometry_area_perimeter(geometry)
|
|
return abs(float(area))
|
|
except Exception:
|
|
return None
|
|
|
|
@staticmethod
|
|
def _persist_fixture_segmentations(
|
|
db,
|
|
project_id: uuid.UUID,
|
|
dataset_id: uuid.UUID,
|
|
analysis_run: AnalysisRun,
|
|
job: Job,
|
|
model_name: str,
|
|
model_version: str | None,
|
|
raw_segmentations: Any,
|
|
confidence_threshold: float,
|
|
class_filter: list[str],
|
|
settings: Settings,
|
|
) -> list[Segmentation]:
|
|
if not isinstance(raw_segmentations, list):
|
|
raise AppError(code="INVALID_FIXTURE_SEGMENTATIONS", message="fixture_segmentations must be a list", status_code=400)
|
|
adapter = FixtureSegmentationAdapter()
|
|
adapter_results = adapter.segment(raw_segmentations)
|
|
if len(adapter_results) != len(raw_segmentations):
|
|
raise AppError(code="INVALID_FIXTURE_SEGMENTATION", message="Each fixture segmentation must be an object", status_code=400)
|
|
persisted: list[Segmentation] = []
|
|
allowed_classes = set(class_filter)
|
|
for raw in adapter_results:
|
|
class_name = raw.class_name
|
|
confidence = raw.confidence
|
|
if allowed_classes and class_name not in allowed_classes:
|
|
continue
|
|
if confidence is not None and confidence < confidence_threshold:
|
|
continue
|
|
if not isinstance(raw.geometry, dict):
|
|
raise AppError(code="INVALID_FIXTURE_SEGMENTATION", message="Fixture segmentation geometry is required", status_code=400)
|
|
geometry = SegmentationService._validated_multipolygon(raw.geometry)
|
|
segmentation_id = uuid.uuid4()
|
|
mask_path = raw.mask_path or SegmentationService.mask_artifact_path(
|
|
settings.storage_root,
|
|
project_id,
|
|
analysis_run.id,
|
|
raw.tile_index,
|
|
segmentation_id,
|
|
)
|
|
segmentation = Segmentation(
|
|
id=segmentation_id,
|
|
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.bbox_json,
|
|
area_m2=raw.area_m2,
|
|
mask_path=mask_path,
|
|
source_tile_path=raw.source_tile_path,
|
|
tile_index=raw.tile_index,
|
|
properties_json=raw.properties_json,
|
|
provenance_json={**dict(raw.provenance_json or {}), "fixture_mode": True},
|
|
)
|
|
db.add(segmentation)
|
|
persisted.append(segmentation)
|
|
db.commit()
|
|
for segmentation in persisted:
|
|
db.refresh(segmentation)
|
|
return persisted
|
|
|
|
@staticmethod
|
|
def _validated_multipolygon(geometry_payload: dict[str, Any]) -> MultiPolygon:
|
|
try:
|
|
geometry = shape(geometry_payload)
|
|
except Exception as exc:
|
|
raise AppError(code="INVALID_FIXTURE_GEOMETRY", message="Fixture segmentation geometry must be valid GeoJSON", status_code=400) from exc
|
|
if geometry.is_empty:
|
|
raise AppError(code="INVALID_FIXTURE_GEOMETRY", message="Fixture segmentation geometry must not be empty", status_code=400)
|
|
if not geometry.is_valid:
|
|
geometry = make_valid(geometry)
|
|
if geometry.is_empty or not geometry.is_valid:
|
|
raise AppError(code="INVALID_FIXTURE_GEOMETRY", message="Fixture segmentation geometry must be valid", status_code=400)
|
|
if isinstance(geometry, Polygon):
|
|
geometry = MultiPolygon([geometry])
|
|
if not isinstance(geometry, MultiPolygon):
|
|
raise AppError(code="INVALID_FIXTURE_GEOMETRY", message="Fixture segmentation geometry must be Polygon or MultiPolygon", status_code=400)
|
|
if geometry.area <= 0:
|
|
raise AppError(code="INVALID_FIXTURE_GEOMETRY", message="Fixture segmentation geometry must have positive area", status_code=400)
|
|
return geometry
|
|
|
|
@staticmethod
|
|
def _query_segmentation_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[Segmentation]:
|
|
query = db.query(Segmentation)
|
|
if analysis_run_id is not None:
|
|
query = query.filter(Segmentation.analysis_run_id == analysis_run_id)
|
|
if dataset_id is not None:
|
|
query = query.filter(Segmentation.dataset_id == dataset_id)
|
|
if class_name:
|
|
query = query.filter(Segmentation.class_name == class_name)
|
|
if min_confidence is not None:
|
|
query = query.filter(Segmentation.confidence >= min_confidence)
|
|
# One transaction timestamp is shared by every row in a run, so
|
|
# ordering by it alone leaves the row order — and therefore the QA
|
|
# score — undefined. See DetectionService._query_detection_rows.
|
|
return query.order_by(
|
|
Segmentation.confidence.desc(),
|
|
Segmentation.created_at.desc(),
|
|
Segmentation.id.asc(),
|
|
).all()
|
|
|
|
@staticmethod
|
|
def _segmentation_properties(segmentation: Segmentation) -> dict[str, Any]:
|
|
return {
|
|
"segmentation_id": str(segmentation.id),
|
|
"class_name": segmentation.class_name,
|
|
"confidence": segmentation.confidence,
|
|
"area_m2": segmentation.area_m2,
|
|
"model_name": segmentation.model_name,
|
|
"model_version": segmentation.model_version,
|
|
"analysis_run_id": str(segmentation.analysis_run_id) if segmentation.analysis_run_id else None,
|
|
"dataset_id": str(segmentation.dataset_id) if segmentation.dataset_id else None,
|
|
"job_id": str(segmentation.job_id) if segmentation.job_id else None,
|
|
"source_tile_path": segmentation.source_tile_path,
|
|
"tile_index": segmentation.tile_index,
|
|
"mask_path": segmentation.mask_path,
|
|
"bbox_json": segmentation.bbox_json,
|
|
"provenance_json": segmentation.provenance_json,
|
|
}
|