Add map area selection extraction
GeoIntel CI / docs-smoke (push) Has been cancelled
GeoIntel CI / contract-smoke (push) Has been cancelled

This commit is contained in:
Codex
2026-06-25 01:59:57 +02:00
parent 00de5dbcb7
commit 851d7220df
18 changed files with 1063 additions and 2 deletions
+25
View File
@@ -24,6 +24,9 @@ from app.schemas import (
VectorBufferRequest,
VectorClipRequest,
VectorIntersectRequest,
VectorSelectionBBox,
VectorSelectionRequest,
VectorSelectionResponse,
)
from app.schemas.job import JobCreate
from app.schemas.dataset import DatasetCreateResponse
@@ -31,6 +34,7 @@ from app.schemas.operations import VectorOperationResult
from app.services.job_service import JobService
from app.services.raster_operations_service import RasterOperationsService
from app.services.vector_operations_service import VectorOperationsService
from app.services.vector_feature_service import VectorFeatureService
from app.services.dataset_service import DatasetService
from app.utils.response import envelope
@@ -180,6 +184,27 @@ def vector_stats(
return envelope(VectorOperationsService.stats(db, dataset_id))
@router.post("/datasets/{dataset_id}/vector/select", response_model=dict)
def select_vector_features(
project_id: UUID,
dataset_id: UUID,
payload: VectorSelectionRequest,
db: Session = Depends(get_db),
):
dataset = DatasetService.get_dataset(db, dataset_id)
if dataset.project_id != project_id:
raise HTTPException(status_code=404, detail="Dataset not found")
if dataset.dataset_type not in {"vector", "geojson"}:
raise AppError(code="DATASET_NOT_VECTOR", message="Area selection requires a vector dataset", status_code=400)
result = VectorFeatureService.select_features_by_bbox(
db,
dataset_id=dataset_id,
bbox=payload.bbox.model_dump(),
limit=payload.limit,
)
return envelope(VectorSelectionResponse(**result).model_dump())
@router.post("/datasets/{dataset_id}/vector/clip", status_code=201, response_model=dict)
def clip_vector_dataset(
project_id: UUID,
+6
View File
@@ -71,6 +71,9 @@ from .operations import (
VectorIntersectRequest,
VectorOperationRequest,
VectorOperationResult,
VectorSelectionBBox,
VectorSelectionRequest,
VectorSelectionResponse,
VectorStatsRequest,
VectorStatsResponse,
)
@@ -122,6 +125,9 @@ __all__ = [
"VectorIntersectRequest",
"VectorOperationRequest",
"VectorOperationResult",
"VectorSelectionBBox",
"VectorSelectionRequest",
"VectorSelectionResponse",
"RasterClipRequest",
"RasterStatsResponse",
"RasterReprojectRequest",
+29 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations
from pydantic import BaseModel
from pydantic import BaseModel, Field, field_validator
class VectorOperationResult(BaseModel):
@@ -191,3 +191,31 @@ class VectorStatsResponse(BaseModel):
geometry_type_summary: dict[str, int]
bounds_json: dict | None
crs: str | None = None
class VectorSelectionBBox(BaseModel):
min_x: float
min_y: float
max_x: float
max_y: float
crs: str = "EPSG:4326"
@field_validator("crs")
@classmethod
def validate_crs(cls, value: str) -> str:
if value.upper() != "EPSG:4326":
raise ValueError("Only EPSG:4326 bbox selection is supported")
return "EPSG:4326"
class VectorSelectionRequest(BaseModel):
bbox: VectorSelectionBBox
limit: int = Field(default=100, ge=1, le=1000)
class VectorSelectionResponse(BaseModel):
selection_bbox: VectorSelectionBBox
feature_count: int
limit: int
truncated: bool
geojson: dict
@@ -3,7 +3,10 @@ from __future__ import annotations
from typing import Any
from uuid import UUID
from geoalchemy2.functions import ST_Intersects, ST_MakeEnvelope
from geoalchemy2.shape import from_shape
from geoalchemy2.shape import to_shape
from shapely.geometry import mapping
from shapely.geometry import shape
from shapely.validation import make_valid
@@ -12,6 +15,117 @@ from app.models import VectorFeature
class VectorFeatureService:
@staticmethod
def _normalize_selection_bbox(bbox: dict[str, Any]) -> dict[str, float | str]:
try:
min_x = float(bbox["min_x"])
min_y = float(bbox["min_y"])
max_x = float(bbox["max_x"])
max_y = float(bbox["max_y"])
except (KeyError, TypeError, ValueError) as exc:
raise AppError(
code="INVALID_SELECTION_BBOX",
message="Selection bbox must include numeric min_x, min_y, max_x and max_y values",
status_code=400,
) from exc
crs = str(bbox.get("crs") or "EPSG:4326").upper()
if crs != "EPSG:4326":
raise AppError(
code="UNSUPPORTED_SELECTION_CRS",
message="Map selection currently supports EPSG:4326 bbox coordinates only",
details={"crs": crs},
status_code=400,
)
if min_x >= max_x or min_y >= max_y:
raise AppError(
code="INVALID_SELECTION_BBOX",
message="Selection bbox must have min_x < max_x and min_y < max_y",
status_code=400,
)
if min_x < -180 or max_x > 180 or min_y < -90 or max_y > 90:
raise AppError(
code="INVALID_SELECTION_BBOX",
message="Selection bbox is outside EPSG:4326 longitude/latitude bounds",
status_code=400,
)
return {"min_x": min_x, "min_y": min_y, "max_x": max_x, "max_y": max_y, "crs": "EPSG:4326"}
@staticmethod
def _row_to_geojson_feature(row: VectorFeature) -> dict[str, Any]:
geometry_value = row.geometry
try:
geometry = geometry_value if hasattr(geometry_value, "__geo_interface__") else to_shape(geometry_value)
except Exception as exc:
raise AppError(
code="INVALID_VECTOR_FEATURE_GEOMETRY",
message="Persisted vector feature geometry could not be converted to GeoJSON",
details={"vector_feature_id": str(row.id)},
status_code=500,
) from exc
properties = dict(row.properties_json or {})
properties.update(
{
"vector_feature_id": str(row.id),
"dataset_id": str(row.dataset_id),
"source_feature_id": row.source_feature_id,
"feature_class": row.feature_class,
}
)
return {
"type": "Feature",
"id": str(row.id),
"geometry": mapping(geometry),
"properties": properties,
}
@staticmethod
def select_features_by_bbox(
db,
dataset_id: UUID,
bbox: dict[str, Any],
limit: int = 100,
) -> dict[str, Any]:
normalized_bbox = VectorFeatureService._normalize_selection_bbox(bbox)
safe_limit = max(1, min(int(limit), 1000))
rows = (
db.query(VectorFeature)
.filter(VectorFeature.dataset_id == dataset_id)
.filter(
ST_Intersects(
VectorFeature.geometry,
ST_MakeEnvelope(
normalized_bbox["min_x"],
normalized_bbox["min_y"],
normalized_bbox["max_x"],
normalized_bbox["max_y"],
4326,
),
)
)
.order_by(VectorFeature.created_at.asc())
.limit(safe_limit + 1)
.all()
)
truncated = len(rows) > safe_limit
selected_rows = rows[:safe_limit]
features = [VectorFeatureService._row_to_geojson_feature(row) for row in selected_rows]
return {
"selection_bbox": normalized_bbox,
"feature_count": len(features),
"limit": safe_limit,
"truncated": truncated,
"geojson": {
"type": "FeatureCollection",
"features": features,
},
}
@staticmethod
def persist_geojson_features(
db,