correct the tiled inference chain and move runs off the request thread
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>
This commit is contained in:
@@ -8,6 +8,7 @@ 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
|
||||
@@ -20,6 +21,7 @@ from app.schemas.segmentation import (
|
||||
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
|
||||
@@ -51,22 +53,11 @@ class SegmentationService:
|
||||
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()
|
||||
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,
|
||||
)
|
||||
dataset = SegmentationService._validate_run_request(db, project_id=project_id, dataset_id=dataset_id)
|
||||
|
||||
model = ModelRegistryService.get_model_capability(
|
||||
model_id,
|
||||
@@ -110,7 +101,7 @@ class SegmentationService:
|
||||
"tile_manifest_path": tile_manifest_path,
|
||||
"parameters_json": parameters,
|
||||
}
|
||||
job = SegmentationService._create_job(db, project_id, dataset_id, run_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:
|
||||
@@ -374,16 +365,98 @@ class SegmentationService:
|
||||
message="Segmentation run has no persisted geometries for QA",
|
||||
status_code=422,
|
||||
)
|
||||
references = db.query(VectorFeature).filter(VectorFeature.dataset_id == reference_dataset_id).all()
|
||||
if not references:
|
||||
# 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,
|
||||
)
|
||||
|
||||
candidate_geometries = [({"id": str(row.id), "class_name": row.class_name}, to_shape(row.geometry)) for row in segmentations]
|
||||
reference_geometries = [({"id": str(row.id), "feature_class": row.feature_class}, to_shape(row.geometry)) for row in references]
|
||||
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,
|
||||
@@ -411,13 +484,15 @@ class SegmentationService:
|
||||
"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": evidence.warnings,
|
||||
"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,
|
||||
@@ -438,6 +513,8 @@ class SegmentationService:
|
||||
"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,
|
||||
@@ -446,7 +523,8 @@ class SegmentationService:
|
||||
"f1_score": f1_score,
|
||||
"mean_iou": mean_iou,
|
||||
"iou_threshold": iou_threshold,
|
||||
"warnings": evidence.warnings,
|
||||
"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,
|
||||
@@ -458,7 +536,24 @@ class SegmentationService:
|
||||
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]) -> Job:
|
||||
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",
|
||||
@@ -474,6 +569,59 @@ class SegmentationService:
|
||||
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(
|
||||
@@ -568,7 +716,7 @@ class SegmentationService:
|
||||
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 = manifest.get("crs") or manifest.get("source_crs") or manifest.get("dataset_crs") or "EPSG:4326"
|
||||
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())
|
||||
@@ -787,7 +935,14 @@ class SegmentationService:
|
||||
query = query.filter(Segmentation.class_name == class_name)
|
||||
if min_confidence is not None:
|
||||
query = query.filter(Segmentation.confidence >= min_confidence)
|
||||
return query.order_by(Segmentation.created_at.desc()).all()
|
||||
# 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]:
|
||||
|
||||
Reference in New Issue
Block a user