310 lines
11 KiB
Python
310 lines
11 KiB
Python
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from uuid import UUID, uuid4
|
|
|
|
from geoalchemy2.shape import from_shape
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.models import Area, Dataset, Metric, Project, QualityCheck
|
|
from app.schemas.demo import DemoWorkflowResponse
|
|
from app.services.geojson_service import parse_geojson_payload
|
|
from app.services.qa_service import QaService
|
|
from app.services.quality_service import QualityService
|
|
from app.services.storage_service import StorageService
|
|
from app.services.vector_feature_service import VectorFeatureService
|
|
from app.utils.geometry import area_m2, geometry_bbox_polygon, normalize_to_multipolygon
|
|
|
|
|
|
class DemoWorkflowService:
|
|
PROJECT_NAME = "GeoIntel Demo - Building QA"
|
|
AREA_NAME = "Demo AOI - Geel buildings"
|
|
REFERENCE_FILENAME = "demo_reference_buildings.geojson"
|
|
CANDIDATE_FILENAME = "demo_predicted_buildings.geojson"
|
|
|
|
@staticmethod
|
|
def _repo_root() -> Path:
|
|
return Path(__file__).resolve().parents[3]
|
|
|
|
@staticmethod
|
|
def _fixture_path(filename: str) -> Path:
|
|
roots: list[Path] = []
|
|
if os.getenv("GEOINTEL_FIXTURES_ROOT"):
|
|
roots.append(Path(os.environ["GEOINTEL_FIXTURES_ROOT"]))
|
|
roots.extend(parent / "fixtures" / "golden" for parent in Path(__file__).resolve().parents)
|
|
roots.append(Path("/app/fixtures/golden"))
|
|
|
|
for root in roots:
|
|
path = root / filename
|
|
if path.exists():
|
|
return path
|
|
return DemoWorkflowService._repo_root() / "fixtures" / "golden" / filename
|
|
|
|
@staticmethod
|
|
def _load_fixture(filename: str) -> tuple[dict, bytes]:
|
|
path = DemoWorkflowService._fixture_path(filename)
|
|
raw = path.read_bytes()
|
|
return json.loads(raw.decode("utf-8")), raw
|
|
|
|
@staticmethod
|
|
def _find_existing_project(db: Session) -> Project | None:
|
|
return (
|
|
db.query(Project)
|
|
.filter(Project.name == DemoWorkflowService.PROJECT_NAME)
|
|
.filter(Project.status != "deleted")
|
|
.first()
|
|
)
|
|
|
|
@staticmethod
|
|
def _create_area(db: Session, project_id: UUID) -> Area:
|
|
geometry = {
|
|
"type": "MultiPolygon",
|
|
"coordinates": [
|
|
[
|
|
[
|
|
[4.30, 51.18],
|
|
[4.45, 51.18],
|
|
[4.45, 51.33],
|
|
[4.30, 51.33],
|
|
[4.30, 51.18],
|
|
]
|
|
]
|
|
],
|
|
}
|
|
multipolygon = normalize_to_multipolygon(geometry)
|
|
area = Area(
|
|
id=uuid4(),
|
|
project_id=project_id,
|
|
name=DemoWorkflowService.AREA_NAME,
|
|
geometry=from_shape(multipolygon, srid=4326),
|
|
original_crs="EPSG:4326",
|
|
area_m2=area_m2(multipolygon),
|
|
bbox=from_shape(geometry_bbox_polygon(multipolygon), srid=4326),
|
|
)
|
|
db.add(area)
|
|
db.commit()
|
|
db.refresh(area)
|
|
return area
|
|
|
|
@staticmethod
|
|
def _create_dataset(
|
|
db: Session,
|
|
*,
|
|
project_id: UUID,
|
|
area_id: UUID,
|
|
filename: str,
|
|
payload: dict,
|
|
raw: bytes,
|
|
role: str,
|
|
source_name: str,
|
|
reference_layer_name: str | None,
|
|
) -> Dataset:
|
|
dataset_id = uuid4()
|
|
storage_info = StorageService.persist_dataset_file(
|
|
project_id=str(project_id),
|
|
dataset_id=str(dataset_id),
|
|
dataset_type="vector",
|
|
original_filename=filename,
|
|
content=raw,
|
|
content_type="application/geo+json",
|
|
)
|
|
metadata = parse_geojson_payload(payload)
|
|
dataset = Dataset(
|
|
id=dataset_id,
|
|
project_id=project_id,
|
|
area_id=area_id,
|
|
name=filename,
|
|
dataset_type="vector",
|
|
source="fixture",
|
|
dataset_role=role,
|
|
source_name=source_name,
|
|
reference_layer_name=reference_layer_name,
|
|
source_metadata={
|
|
"fixture": True,
|
|
"fixture_name": filename,
|
|
"usage": "offline demo workflow only",
|
|
},
|
|
provenance_metadata={
|
|
"created_by": "demo_workflow",
|
|
"source_path": str(DemoWorkflowService._fixture_path(filename)),
|
|
},
|
|
imported_at=datetime.now(timezone.utc),
|
|
storage_path=storage_info["storage_path"],
|
|
original_filename=storage_info["original_filename"],
|
|
stored_filename=storage_info["stored_filename"],
|
|
content_type=storage_info["content_type"],
|
|
size_bytes=storage_info["size_bytes"],
|
|
checksum_sha256=storage_info["checksum_sha256"],
|
|
crs=metadata.get("crs"),
|
|
bounds_json=metadata.get("bounds_json"),
|
|
metadata_json=metadata,
|
|
status="ready",
|
|
)
|
|
db.add(dataset)
|
|
db.commit()
|
|
db.refresh(dataset)
|
|
VectorFeatureService.persist_geojson_features(
|
|
db=db,
|
|
dataset_id=dataset.id,
|
|
payload=payload,
|
|
feature_class=reference_layer_name or "building",
|
|
)
|
|
return dataset
|
|
|
|
@staticmethod
|
|
def _persist_qa(
|
|
db: Session,
|
|
*,
|
|
project_id: UUID,
|
|
candidate_dataset_id: UUID,
|
|
reference_dataset_id: UUID,
|
|
area_id: UUID,
|
|
) -> QualityCheck:
|
|
result = QaService.compare_candidate_with_reference(
|
|
db=db,
|
|
project_id=project_id,
|
|
candidate_dataset_id=candidate_dataset_id,
|
|
reference_dataset_id=reference_dataset_id,
|
|
iou_threshold=0.5,
|
|
area_id=area_id,
|
|
)
|
|
return QualityService.persist_quality_check(
|
|
db=db,
|
|
project_id=project_id,
|
|
candidate_dataset_id=candidate_dataset_id,
|
|
reference_dataset_id=reference_dataset_id,
|
|
check_type="demo_candidate_vs_reference",
|
|
status=result.status,
|
|
score=result.f1_score,
|
|
parameters={
|
|
"iou_threshold": result.iou_threshold,
|
|
"area_id": str(area_id),
|
|
"fixture_workflow": True,
|
|
},
|
|
findings={
|
|
"matches": result.matches,
|
|
"false_positives": result.false_positives,
|
|
"false_negatives": result.false_negatives,
|
|
"warnings": result.warnings,
|
|
"unsupported_geometry": result.unsupported_geometry,
|
|
"unsupported_geometries": result.unsupported_geometries,
|
|
},
|
|
metrics={
|
|
"precision": result.precision,
|
|
"recall": result.recall,
|
|
"f1": result.f1_score,
|
|
"mean_iou": result.mean_iou,
|
|
"false_positive_count": result.false_positives,
|
|
"false_negative_count": result.false_negatives,
|
|
},
|
|
)
|
|
|
|
@staticmethod
|
|
def seed(db: Session) -> DemoWorkflowResponse:
|
|
existing = DemoWorkflowService._find_existing_project(db)
|
|
reference_payload, reference_raw = DemoWorkflowService._load_fixture("reference_buildings.geojson")
|
|
candidate_payload, candidate_raw = DemoWorkflowService._load_fixture("predicted_buildings.geojson")
|
|
if existing:
|
|
area = db.query(Area).filter(Area.project_id == existing.id).order_by(Area.created_at.asc()).first()
|
|
reference = (
|
|
db.query(Dataset)
|
|
.filter(Dataset.project_id == existing.id)
|
|
.filter(Dataset.dataset_role == "reference")
|
|
.filter(Dataset.source_name == "fixture")
|
|
.first()
|
|
)
|
|
candidate = (
|
|
db.query(Dataset)
|
|
.filter(Dataset.project_id == existing.id)
|
|
.filter(Dataset.dataset_role == "source")
|
|
.filter(Dataset.source_name == "fixture")
|
|
.first()
|
|
)
|
|
quality_check = (
|
|
db.query(QualityCheck)
|
|
.filter(QualityCheck.project_id == existing.id)
|
|
.filter(QualityCheck.check_type == "demo_candidate_vs_reference")
|
|
.order_by(QualityCheck.created_at.desc())
|
|
.first()
|
|
)
|
|
if area and reference and candidate and quality_check:
|
|
return DemoWorkflowResponse(
|
|
project_id=existing.id,
|
|
area_id=area.id,
|
|
reference_dataset_id=reference.id,
|
|
candidate_dataset_id=candidate.id,
|
|
quality_check_id=quality_check.id,
|
|
metric_count=db.query(Metric).filter(Metric.quality_check_id == quality_check.id).count(),
|
|
status="ready",
|
|
message="Demo workflow already exists.",
|
|
created=False,
|
|
)
|
|
project = existing
|
|
created = True
|
|
else:
|
|
project = Project(
|
|
id=uuid4(),
|
|
name=DemoWorkflowService.PROJECT_NAME,
|
|
description="Offline fixture workflow: reference buildings, predicted buildings and persisted QA metrics.",
|
|
region="Kempen",
|
|
status="active",
|
|
)
|
|
db.add(project)
|
|
db.commit()
|
|
db.refresh(project)
|
|
area = None
|
|
reference = None
|
|
candidate = None
|
|
quality_check = None
|
|
created = True
|
|
|
|
if not area:
|
|
area = DemoWorkflowService._create_area(db, project.id)
|
|
if not reference:
|
|
reference = DemoWorkflowService._create_dataset(
|
|
db=db,
|
|
project_id=project.id,
|
|
area_id=area.id,
|
|
filename=DemoWorkflowService.REFERENCE_FILENAME,
|
|
payload=reference_payload,
|
|
raw=reference_raw,
|
|
role="reference",
|
|
source_name="fixture",
|
|
reference_layer_name="buildings",
|
|
)
|
|
if not candidate:
|
|
candidate = DemoWorkflowService._create_dataset(
|
|
db=db,
|
|
project_id=project.id,
|
|
area_id=area.id,
|
|
filename=DemoWorkflowService.CANDIDATE_FILENAME,
|
|
payload=candidate_payload,
|
|
raw=candidate_raw,
|
|
role="source",
|
|
source_name="fixture",
|
|
reference_layer_name=None,
|
|
)
|
|
if not quality_check:
|
|
quality_check = DemoWorkflowService._persist_qa(
|
|
db=db,
|
|
project_id=project.id,
|
|
candidate_dataset_id=candidate.id,
|
|
reference_dataset_id=reference.id,
|
|
area_id=area.id,
|
|
)
|
|
|
|
return DemoWorkflowResponse(
|
|
project_id=project.id,
|
|
area_id=area.id,
|
|
reference_dataset_id=reference.id,
|
|
candidate_dataset_id=candidate.id,
|
|
quality_check_id=quality_check.id,
|
|
metric_count=6,
|
|
status="ready",
|
|
message="Demo workflow seeded from explicit local fixtures.",
|
|
created=created,
|
|
)
|