Add Gamma SBAS production workflow
This commit is contained in:
@@ -0,0 +1,967 @@
|
||||
"""Flood-analysis service layer.
|
||||
|
||||
This module owns the flood business API implementation used by
|
||||
``backend.app.routers.flood``. It intentionally does not import the legacy
|
||||
water router; the old router remains only as a compatibility surface.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import select, func
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm import selectinload
|
||||
|
||||
from ..models import FloodDetectionORM, RadarDataORM, SARSceneGeoORM, WaterDetectionORM, WaterExtractionORM
|
||||
from ..services.job_handlers import (
|
||||
JOB_TYPE_FLOOD_DETECTION,
|
||||
JOB_TYPE_SAR_SCENE_PREPROCESS,
|
||||
JOB_TYPE_WATER_DETECT,
|
||||
)
|
||||
from ..services.job_queue_service import job_queue_service
|
||||
from ..services.task_service import task_service
|
||||
from ..utils import normalize_satellite_family
|
||||
|
||||
_FLOOD_JOB_MAX_ATTEMPTS = 3
|
||||
|
||||
|
||||
async def _queue_flood_job(
|
||||
*,
|
||||
job_type: str,
|
||||
task_type: str,
|
||||
task_name: str,
|
||||
payload: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
task_id = await task_service.create_task(
|
||||
task_type=task_type,
|
||||
task_name=task_name,
|
||||
params=payload,
|
||||
)
|
||||
job_id = await job_queue_service.create_job(
|
||||
job_type=job_type,
|
||||
payload=payload,
|
||||
task_id=task_id,
|
||||
max_attempts=_FLOOD_JOB_MAX_ATTEMPTS,
|
||||
)
|
||||
return {"task_id": task_id, "job_id": job_id, "job_type": job_type, "message": "Job queued."}
|
||||
except ValueError as exc:
|
||||
message = str(exc)
|
||||
raise HTTPException(status_code=409 if "conflict" in message.lower() else 400, detail=message) from exc
|
||||
|
||||
|
||||
def _overlap_ratio(poly_a: Any, poly_b: Any) -> float:
|
||||
try:
|
||||
import json
|
||||
from shapely.geometry import Polygon, shape
|
||||
|
||||
def _to_geom(poly: Any):
|
||||
if isinstance(poly, str):
|
||||
poly = json.loads(poly)
|
||||
if isinstance(poly, list):
|
||||
return Polygon(poly)
|
||||
return shape(poly)
|
||||
|
||||
a = _to_geom(poly_a)
|
||||
b = _to_geom(poly_b)
|
||||
if not a.is_valid or not b.is_valid:
|
||||
return 0.0
|
||||
intersection_area = a.intersection(b).area
|
||||
smaller_area = min(a.area, b.area)
|
||||
return intersection_area / smaller_area if smaller_area > 0 else 0.0
|
||||
except Exception:
|
||||
return 0.0
|
||||
|
||||
|
||||
def _parse_ymd(value: str | None, *, field: str) -> datetime:
|
||||
try:
|
||||
normalized = str(value or "").replace("-", "").strip()
|
||||
return datetime.strptime(normalized, "%Y%m%d")
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=400, detail=f"{field} must be YYYYMMDD") from exc
|
||||
|
||||
|
||||
def _format_ymd(value: datetime) -> str:
|
||||
return value.strftime("%Y%m%d")
|
||||
|
||||
|
||||
def _to_float(value: Any, default: float = 0.0) -> float:
|
||||
try:
|
||||
if value is None:
|
||||
return default
|
||||
return float(value)
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
def _same_text(left: Any, right: Any) -> bool:
|
||||
left_text = str(left or "").strip().lower()
|
||||
right_text = str(right or "").strip().lower()
|
||||
if not left_text or not right_text:
|
||||
return True
|
||||
return left_text == right_text
|
||||
|
||||
|
||||
def _feature_collection_name(feature_collection: dict[str, Any]) -> str | None:
|
||||
try:
|
||||
features = feature_collection.get("features") or []
|
||||
properties = features[0].get("properties") or {}
|
||||
return properties.get("name") or properties.get("NAME") or properties.get("treeID")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _preprocess_engine_for_radar(radar: RadarDataORM) -> str | None:
|
||||
family = str(normalize_satellite_family(radar.satellite_family or radar.satellite) or "").upper()
|
||||
if family == "GF3":
|
||||
return "gf3_gdal"
|
||||
if family == "LT1":
|
||||
return "lt_gamma"
|
||||
return None
|
||||
|
||||
|
||||
def _scene_analysis_path(scene: SARSceneGeoORM | None) -> str | None:
|
||||
if not scene:
|
||||
return None
|
||||
return scene.analysis_tif_path
|
||||
|
||||
|
||||
def _resolve_aoi_wkt_from_request(req: Any) -> tuple[str, dict[str, Any], dict[str, Any]]:
|
||||
"""Resolve region/GeoJSON AOI using the same parser as the management search page."""
|
||||
aoi_geojson = getattr(req, "aoi_geojson", None)
|
||||
region_tree_id = getattr(req, "region_tree_id", None)
|
||||
|
||||
if aoi_geojson:
|
||||
feature_collection = aoi_geojson
|
||||
source = "geojson"
|
||||
elif region_tree_id:
|
||||
from ..routers.dependencies import _resolve_region_aoi_payload
|
||||
|
||||
payload = _resolve_region_aoi_payload(str(region_tree_id))
|
||||
feature_collection = payload.get("aoi_geojson") or payload
|
||||
source = "region"
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="region_tree_id or aoi_geojson is required")
|
||||
|
||||
from ..routers.dependencies import _parse_aoi_geojson_form_value
|
||||
|
||||
parsed = _parse_aoi_geojson_form_value(json.dumps(feature_collection, ensure_ascii=False))
|
||||
if not parsed:
|
||||
raise HTTPException(status_code=400, detail="AOI geometry is empty")
|
||||
aoi_wkt, normalized_feature_collection = parsed
|
||||
meta = {
|
||||
"source": source,
|
||||
"region_tree_id": region_tree_id,
|
||||
"name": _feature_collection_name(normalized_feature_collection),
|
||||
}
|
||||
return aoi_wkt, normalized_feature_collection, meta
|
||||
|
||||
|
||||
def _radar_scene_item(scene: SARSceneGeoORM, radar: RadarDataORM, *, aoi_coverage_ratio: float | None = None) -> dict[str, Any]:
|
||||
return {
|
||||
"id": scene.id,
|
||||
"scene_id": scene.id,
|
||||
"radar_data_id": scene.radar_data_id,
|
||||
"satellite": radar.satellite,
|
||||
"imaging_date": radar.imaging_date,
|
||||
"acquisition_time_utc": radar.acquisition_time_utc,
|
||||
"imaging_mode": radar.imaging_mode,
|
||||
"product_level": radar.product_level,
|
||||
"polarization": radar.polarization,
|
||||
"orbit_direction": radar.orbit_direction,
|
||||
"geo_path": scene.geo_path,
|
||||
"analysis_tif_path": scene.analysis_tif_path,
|
||||
"analysis_dir": scene.analysis_dir,
|
||||
"analysis_preview_path": scene.analysis_preview_path,
|
||||
"analysis_engine": scene.analysis_engine,
|
||||
"analysis_profile": scene.analysis_profile,
|
||||
"analysis_backscatter_unit": scene.analysis_backscatter_unit,
|
||||
"analysis_quality_json": scene.analysis_quality_json,
|
||||
"coverage_polygon": radar.coverage_polygon,
|
||||
"min_lat": radar.min_lat,
|
||||
"max_lat": radar.max_lat,
|
||||
"min_lon": radar.min_lon,
|
||||
"max_lon": radar.max_lon,
|
||||
"aoi_coverage_ratio": aoi_coverage_ratio,
|
||||
}
|
||||
|
||||
|
||||
async def _query_disaster_scene_pool(
|
||||
*,
|
||||
db: AsyncSession,
|
||||
req: Any,
|
||||
aoi_wkt: str,
|
||||
start_ymd: str,
|
||||
end_ymd: str,
|
||||
min_aoi_coverage_ratio: float,
|
||||
descending: bool,
|
||||
) -> list[dict[str, Any]]:
|
||||
aoi_geom = func.ST_GeomFromText(aoi_wkt, 4326)
|
||||
aoi_area = func.ST_Area(func.Geography(aoi_geom))
|
||||
coverage_expr = (
|
||||
func.ST_Area(func.Geography(func.ST_Intersection(RadarDataORM.geom, aoi_geom)))
|
||||
/ func.nullif(aoi_area, 0)
|
||||
).label("aoi_coverage_ratio")
|
||||
|
||||
filters = [
|
||||
SARSceneGeoORM.status == "DONE",
|
||||
SARSceneGeoORM.analysis_tif_path.isnot(None),
|
||||
RadarDataORM.geom.isnot(None),
|
||||
RadarDataORM.imaging_date.isnot(None),
|
||||
RadarDataORM.imaging_date >= start_ymd,
|
||||
RadarDataORM.imaging_date <= end_ymd,
|
||||
func.ST_Intersects(RadarDataORM.geom, aoi_geom),
|
||||
]
|
||||
|
||||
satellites = [str(item).strip() for item in (getattr(req, "satellites", None) or []) if str(item).strip()]
|
||||
if satellites:
|
||||
filters.append(RadarDataORM.satellite.in_(satellites))
|
||||
|
||||
polarization = str(getattr(req, "polarization", "") or "").strip()
|
||||
if polarization:
|
||||
filters.append(RadarDataORM.polarization.ilike(f"%{polarization}%"))
|
||||
|
||||
imaging_mode = str(getattr(req, "imaging_mode", "") or "").strip()
|
||||
if imaging_mode:
|
||||
filters.append(RadarDataORM.imaging_mode == imaging_mode)
|
||||
|
||||
product_level = str(getattr(req, "product_level", "") or "").strip()
|
||||
if product_level:
|
||||
filters.append(RadarDataORM.product_level == product_level)
|
||||
|
||||
order_by = RadarDataORM.imaging_date.desc() if descending else RadarDataORM.imaging_date.asc()
|
||||
result = await db.execute(
|
||||
select(SARSceneGeoORM, RadarDataORM, coverage_expr)
|
||||
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
||||
.where(*filters)
|
||||
.order_by(order_by, SARSceneGeoORM.id.desc())
|
||||
)
|
||||
|
||||
pool: list[dict[str, Any]] = []
|
||||
for scene, radar, aoi_coverage_ratio in result.all():
|
||||
coverage_ratio = max(0.0, min(1.0, _to_float(aoi_coverage_ratio)))
|
||||
if coverage_ratio < min_aoi_coverage_ratio:
|
||||
continue
|
||||
pool.append(_radar_scene_item(scene, radar, aoi_coverage_ratio=round(coverage_ratio, 4)))
|
||||
return pool
|
||||
|
||||
|
||||
async def submit_geocode_job(req: Any, db: AsyncSession) -> dict[str, Any]:
|
||||
radar = await db.get(RadarDataORM, req.radar_data_id)
|
||||
if not radar:
|
||||
raise HTTPException(status_code=404, detail=f"RadarData id={req.radar_data_id} not found")
|
||||
|
||||
result = await db.execute(
|
||||
select(SARSceneGeoORM)
|
||||
.where(SARSceneGeoORM.radar_data_id == req.radar_data_id)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
scene = result.scalar_one_or_none()
|
||||
if scene and scene.status in ("PENDING", "RUNNING"):
|
||||
raise HTTPException(status_code=409, detail="Scene already has an active geocode job")
|
||||
if not scene:
|
||||
scene = SARSceneGeoORM(radar_data_id=req.radar_data_id, status="PENDING")
|
||||
db.add(scene)
|
||||
await db.flush()
|
||||
else:
|
||||
scene.status = "PENDING"
|
||||
scene.error_msg = None
|
||||
await db.flush()
|
||||
scene_id = scene.id
|
||||
await db.commit()
|
||||
|
||||
engine = _preprocess_engine_for_radar(radar)
|
||||
if not engine:
|
||||
async with db.begin():
|
||||
failed_scene = await db.get(SARSceneGeoORM, scene_id)
|
||||
if failed_scene and failed_scene.status == "PENDING":
|
||||
failed_scene.status = "FAILED"
|
||||
failed_scene.error_msg = "No analysis-ready GeoTIFF preprocessor configured for this satellite"
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="洪涝模块不再使用 ENVI 兜底预处理;该卫星暂未配置 analysis-ready GeoTIFF 预处理器",
|
||||
)
|
||||
|
||||
job_type = JOB_TYPE_SAR_SCENE_PREPROCESS
|
||||
task_type = f"FLOOD_SCENE_PREPROCESS_{scene_id}"
|
||||
task_name = f"Flood analysis-ready preprocess radar_id={req.radar_data_id} engine={engine}"
|
||||
payload = {"scene_id": scene_id, "radar_data_id": req.radar_data_id}
|
||||
payload["engine"] = engine
|
||||
|
||||
try:
|
||||
return await _queue_flood_job(
|
||||
job_type=job_type,
|
||||
task_type=task_type,
|
||||
task_name=task_name,
|
||||
payload=payload,
|
||||
)
|
||||
except HTTPException:
|
||||
async with db.begin():
|
||||
failed_scene = await db.get(SARSceneGeoORM, scene_id)
|
||||
if failed_scene and failed_scene.status == "PENDING":
|
||||
failed_scene.status = "FAILED"
|
||||
failed_scene.error_msg = "Job queue failed"
|
||||
raise
|
||||
|
||||
|
||||
async def reset_scene_status(scene_id: int, db: AsyncSession) -> dict[str, Any]:
|
||||
scene = await db.get(SARSceneGeoORM, scene_id)
|
||||
if not scene:
|
||||
raise HTTPException(status_code=404, detail=f"Scene id={scene_id} not found")
|
||||
if scene.status not in ("PENDING", "RUNNING"):
|
||||
raise HTTPException(status_code=400, detail=f"Scene status is {scene.status}; reset is not needed")
|
||||
scene.status = "FAILED"
|
||||
scene.error_msg = "Manually reset"
|
||||
await db.commit()
|
||||
return {"id": scene_id, "status": "FAILED", "message": "Scene reset"}
|
||||
|
||||
|
||||
async def list_done_scene_radar_ids(db: AsyncSession) -> dict[str, list[int]]:
|
||||
result = await db.execute(
|
||||
select(SARSceneGeoORM.radar_data_id).where(
|
||||
SARSceneGeoORM.status == "DONE",
|
||||
SARSceneGeoORM.analysis_tif_path.isnot(None),
|
||||
)
|
||||
)
|
||||
return {"ids": [row for (row,) in result.all()]}
|
||||
|
||||
|
||||
async def list_active_scene_radar_ids(db: AsyncSession) -> dict[str, list[int]]:
|
||||
result = await db.execute(
|
||||
select(SARSceneGeoORM.radar_data_id).where(SARSceneGeoORM.status.in_(["PENDING", "RUNNING"]))
|
||||
)
|
||||
return {"ids": [row for (row,) in result.all()]}
|
||||
|
||||
|
||||
async def list_scenes(limit: int, offset: int, db: AsyncSession) -> dict[str, Any]:
|
||||
total_result = await db.execute(select(func.count()).select_from(SARSceneGeoORM))
|
||||
total = total_result.scalar_one()
|
||||
|
||||
result = await db.execute(
|
||||
select(SARSceneGeoORM, RadarDataORM)
|
||||
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
||||
.order_by(SARSceneGeoORM.id.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
|
||||
items = []
|
||||
for scene, radar in result.all():
|
||||
items.append(
|
||||
{
|
||||
"id": scene.id,
|
||||
"radar_data_id": scene.radar_data_id,
|
||||
"satellite": radar.satellite,
|
||||
"imaging_date": radar.imaging_date,
|
||||
"acquisition_time_utc": radar.acquisition_time_utc,
|
||||
"imaging_mode": radar.imaging_mode,
|
||||
"product_level": radar.product_level,
|
||||
"polarization": radar.polarization,
|
||||
"orbit_direction": radar.orbit_direction,
|
||||
"geo_path": scene.geo_path,
|
||||
"analysis_tif_path": scene.analysis_tif_path,
|
||||
"analysis_dir": scene.analysis_dir,
|
||||
"analysis_preview_path": scene.analysis_preview_path,
|
||||
"analysis_engine": scene.analysis_engine,
|
||||
"analysis_profile": scene.analysis_profile,
|
||||
"analysis_backscatter_unit": scene.analysis_backscatter_unit,
|
||||
"analysis_nodata_value": scene.analysis_nodata_value,
|
||||
"analysis_metadata_json": scene.analysis_metadata_json,
|
||||
"analysis_quality_json": scene.analysis_quality_json,
|
||||
"pixel_size_m": scene.pixel_size_m,
|
||||
"status": scene.status,
|
||||
"error_msg": scene.error_msg,
|
||||
"created_at": scene.created_at.isoformat() if scene.created_at else None,
|
||||
"coverage_polygon": radar.coverage_polygon,
|
||||
"min_lat": radar.min_lat,
|
||||
"max_lat": radar.max_lat,
|
||||
"min_lon": radar.min_lon,
|
||||
"max_lon": radar.max_lon,
|
||||
}
|
||||
)
|
||||
return {"items": items, "total": total}
|
||||
|
||||
|
||||
async def submit_water_extraction(req: Any, db: AsyncSession) -> dict[str, Any]:
|
||||
input_path = req.input_path
|
||||
scene_id = req.scene_id
|
||||
|
||||
if scene_id:
|
||||
scene = await db.get(SARSceneGeoORM, scene_id)
|
||||
if not scene:
|
||||
raise HTTPException(status_code=404, detail=f"SARSceneGeoORM id={scene_id} not found")
|
||||
input_path = _scene_analysis_path(scene)
|
||||
if not input_path:
|
||||
raise HTTPException(status_code=400, detail="Scene has no analysis-ready GeoTIFF")
|
||||
|
||||
if not input_path:
|
||||
raise HTTPException(status_code=400, detail="scene_id or input_path is required")
|
||||
|
||||
extraction = WaterExtractionORM(
|
||||
scene_id=scene_id,
|
||||
processor=getattr(req, "processor", None) or "otsu",
|
||||
input_path=input_path,
|
||||
status="PENDING",
|
||||
)
|
||||
db.add(extraction)
|
||||
await db.flush()
|
||||
extraction_id = extraction.id
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
queued = await _queue_flood_job(
|
||||
job_type=JOB_TYPE_WATER_DETECT,
|
||||
task_type=f"FLOOD_WATER_EXTRACTION_{extraction_id}",
|
||||
task_name=f"Flood water extraction id={extraction_id}",
|
||||
payload={"extraction_id": extraction_id, "processor": extraction.processor},
|
||||
)
|
||||
async with db.begin():
|
||||
queued_extraction = await db.get(WaterExtractionORM, extraction_id)
|
||||
if queued_extraction:
|
||||
queued_extraction.task_id = queued.get("task_id")
|
||||
return queued
|
||||
except HTTPException:
|
||||
async with db.begin():
|
||||
failed_extraction = await db.get(WaterExtractionORM, extraction_id)
|
||||
if failed_extraction and failed_extraction.status == "PENDING":
|
||||
failed_extraction.status = "FAILED"
|
||||
failed_extraction.error_msg = "Job queue failed"
|
||||
raise
|
||||
|
||||
|
||||
async def list_water_extractions(
|
||||
*,
|
||||
limit: int,
|
||||
offset: int,
|
||||
status: str | None,
|
||||
db: AsyncSession,
|
||||
) -> dict[str, Any]:
|
||||
count_query = select(func.count()).select_from(WaterExtractionORM)
|
||||
if status:
|
||||
count_query = count_query.where(WaterExtractionORM.status == status)
|
||||
total = (await db.execute(count_query)).scalar_one()
|
||||
|
||||
query = (
|
||||
select(WaterExtractionORM, SARSceneGeoORM, RadarDataORM)
|
||||
.outerjoin(SARSceneGeoORM, WaterExtractionORM.scene_id == SARSceneGeoORM.id)
|
||||
.outerjoin(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
||||
.order_by(WaterExtractionORM.id.desc())
|
||||
.limit(limit)
|
||||
.offset(offset)
|
||||
)
|
||||
if status:
|
||||
query = query.where(WaterExtractionORM.status == status)
|
||||
rows = (await db.execute(query)).all()
|
||||
|
||||
items = []
|
||||
for detection, scene, radar in rows:
|
||||
items.append(
|
||||
{
|
||||
"id": detection.id,
|
||||
"scene_id": detection.scene_id,
|
||||
"processor": detection.processor,
|
||||
"task_id": detection.task_id,
|
||||
"radar_data_id": scene.radar_data_id if scene else None,
|
||||
"satellite": radar.satellite if radar else None,
|
||||
"imaging_date": radar.imaging_date if radar else None,
|
||||
"acquisition_time_utc": radar.acquisition_time_utc if radar else None,
|
||||
"imaging_mode": radar.imaging_mode if radar else None,
|
||||
"product_level": radar.product_level if radar else None,
|
||||
"polarization": radar.polarization if radar else None,
|
||||
"orbit_direction": radar.orbit_direction if radar else None,
|
||||
"coverage_polygon": radar.coverage_polygon if radar else None,
|
||||
"min_lat": radar.min_lat if radar else None,
|
||||
"max_lat": radar.max_lat if radar else None,
|
||||
"min_lon": radar.min_lon if radar else None,
|
||||
"max_lon": radar.max_lon if radar else None,
|
||||
"input_path": detection.input_path,
|
||||
"output_path": detection.output_path,
|
||||
"preview_path": detection.preview_path,
|
||||
"vector_path": detection.vector_path,
|
||||
"water_area_km2": detection.water_area_km2,
|
||||
"water_pixel_count": detection.water_pixel_count,
|
||||
"otsu_threshold_db": detection.threshold_value,
|
||||
"threshold_value": detection.threshold_value,
|
||||
"metadata_json": detection.metadata_json,
|
||||
"status": detection.status,
|
||||
"error_msg": detection.error_msg,
|
||||
"created_at": detection.created_at.isoformat() if detection.created_at else None,
|
||||
"updated_at": detection.updated_at.isoformat() if detection.updated_at else None,
|
||||
}
|
||||
)
|
||||
return {"items": items, "total": total}
|
||||
|
||||
|
||||
async def submit_flood_detection(req: Any, db: AsyncSession) -> dict[str, Any]:
|
||||
pre_scene = await db.get(SARSceneGeoORM, req.pre_scene_id)
|
||||
post_scene = await db.get(SARSceneGeoORM, req.post_scene_id)
|
||||
if not pre_scene:
|
||||
raise HTTPException(status_code=404, detail=f"Pre-scene id={req.pre_scene_id} not found")
|
||||
if not post_scene:
|
||||
raise HTTPException(status_code=404, detail=f"Post-scene id={req.post_scene_id} not found")
|
||||
if pre_scene.status != "DONE":
|
||||
raise HTTPException(status_code=400, detail=f"Pre-scene is not DONE: {pre_scene.status}")
|
||||
if post_scene.status != "DONE":
|
||||
raise HTTPException(status_code=400, detail=f"Post-scene is not DONE: {post_scene.status}")
|
||||
if not pre_scene.analysis_tif_path:
|
||||
raise HTTPException(status_code=400, detail="Pre-scene has no analysis-ready GeoTIFF")
|
||||
if not post_scene.analysis_tif_path:
|
||||
raise HTTPException(status_code=400, detail="Post-scene has no analysis-ready GeoTIFF")
|
||||
|
||||
result = await db.execute(
|
||||
select(FloodDetectionORM)
|
||||
.where(
|
||||
FloodDetectionORM.pre_scene_id == req.pre_scene_id,
|
||||
FloodDetectionORM.post_scene_id == req.post_scene_id,
|
||||
)
|
||||
.with_for_update(skip_locked=True)
|
||||
)
|
||||
detection = result.scalar_one_or_none()
|
||||
if detection and detection.status in ("PENDING", "RUNNING"):
|
||||
raise HTTPException(status_code=409, detail="Pair already has an active flood-detection job")
|
||||
if not detection:
|
||||
detection = FloodDetectionORM(
|
||||
pre_scene_id=req.pre_scene_id,
|
||||
post_scene_id=req.post_scene_id,
|
||||
status="PENDING",
|
||||
)
|
||||
db.add(detection)
|
||||
await db.flush()
|
||||
else:
|
||||
detection.status = "PENDING"
|
||||
detection.error_msg = None
|
||||
await db.flush()
|
||||
detection_id = detection.id
|
||||
await db.commit()
|
||||
|
||||
try:
|
||||
return await _queue_flood_job(
|
||||
job_type=JOB_TYPE_FLOOD_DETECTION,
|
||||
task_type=f"FLOOD_DETECTION_{detection_id}",
|
||||
task_name=f"GeoTIFF flood detection pre={req.pre_scene_id} post={req.post_scene_id}",
|
||||
payload={"detection_id": detection_id, "refine": req.refine},
|
||||
)
|
||||
except HTTPException:
|
||||
async with db.begin():
|
||||
failed_detection = await db.get(FloodDetectionORM, detection_id)
|
||||
if failed_detection and failed_detection.status == "PENDING":
|
||||
failed_detection.status = "FAILED"
|
||||
failed_detection.error_msg = "Job queue failed"
|
||||
raise
|
||||
|
||||
|
||||
async def list_flood_detections(db: AsyncSession) -> dict[str, Any]:
|
||||
result = await db.execute(
|
||||
select(FloodDetectionORM)
|
||||
.options(
|
||||
selectinload(FloodDetectionORM.pre_scene).selectinload(SARSceneGeoORM.radar_data),
|
||||
selectinload(FloodDetectionORM.post_scene).selectinload(SARSceneGeoORM.radar_data),
|
||||
)
|
||||
.order_by(FloodDetectionORM.id.desc())
|
||||
)
|
||||
detections = result.scalars().all()
|
||||
|
||||
items = []
|
||||
for detection in detections:
|
||||
pre_radar = detection.pre_scene.radar_data if detection.pre_scene else None
|
||||
post_radar = detection.post_scene.radar_data if detection.post_scene else None
|
||||
items.append(
|
||||
{
|
||||
"id": detection.id,
|
||||
"pre_scene_id": detection.pre_scene_id,
|
||||
"post_scene_id": detection.post_scene_id,
|
||||
"pre_imaging_date": pre_radar.imaging_date if pre_radar else None,
|
||||
"post_imaging_date": post_radar.imaging_date if post_radar else None,
|
||||
"pre_satellite": pre_radar.satellite if pre_radar else None,
|
||||
"post_satellite": post_radar.satellite if post_radar else None,
|
||||
"pre_geo_path": _scene_analysis_path(detection.pre_scene),
|
||||
"post_geo_path": _scene_analysis_path(detection.post_scene),
|
||||
"pre_analysis_tif_path": _scene_analysis_path(detection.pre_scene),
|
||||
"post_analysis_tif_path": _scene_analysis_path(detection.post_scene),
|
||||
"classified_path": detection.classified_path,
|
||||
"flood_area_km2": detection.flood_area_km2,
|
||||
"stable_water_area_km2": detection.stable_water_area_km2,
|
||||
"status": detection.status,
|
||||
"error_msg": detection.error_msg,
|
||||
"created_at": detection.created_at.isoformat() if detection.created_at else None,
|
||||
"updated_at": detection.updated_at.isoformat() if detection.updated_at else None,
|
||||
}
|
||||
)
|
||||
return {"items": items, "total": len(items)}
|
||||
|
||||
|
||||
async def search_pairs(req: Any, db: AsyncSession) -> dict[str, Any]:
|
||||
pre_filters = [SARSceneGeoORM.status == "DONE", SARSceneGeoORM.analysis_tif_path.isnot(None)]
|
||||
if req.pre_start:
|
||||
pre_filters.append(RadarDataORM.imaging_date >= req.pre_start)
|
||||
if req.pre_end:
|
||||
pre_filters.append(RadarDataORM.imaging_date <= req.pre_end)
|
||||
pre_result = await db.execute(
|
||||
select(SARSceneGeoORM, RadarDataORM)
|
||||
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
||||
.where(*pre_filters)
|
||||
)
|
||||
|
||||
post_filters = [SARSceneGeoORM.status == "DONE", SARSceneGeoORM.analysis_tif_path.isnot(None)]
|
||||
if req.post_start:
|
||||
post_filters.append(RadarDataORM.imaging_date >= req.post_start)
|
||||
if req.post_end:
|
||||
post_filters.append(RadarDataORM.imaging_date <= req.post_end)
|
||||
post_result = await db.execute(
|
||||
select(SARSceneGeoORM, RadarDataORM)
|
||||
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
|
||||
.where(*post_filters)
|
||||
)
|
||||
|
||||
candidates = []
|
||||
for pre_scene, pre_radar in pre_result.all():
|
||||
for post_scene, post_radar in post_result.all():
|
||||
if pre_scene.id == post_scene.id:
|
||||
continue
|
||||
ratio = 0.0
|
||||
if pre_radar.coverage_polygon and post_radar.coverage_polygon:
|
||||
ratio = _overlap_ratio(pre_radar.coverage_polygon, post_radar.coverage_polygon)
|
||||
if ratio < req.overlap_threshold:
|
||||
continue
|
||||
try:
|
||||
pre_date = datetime.strptime(pre_radar.imaging_date, "%Y%m%d")
|
||||
post_date = datetime.strptime(post_radar.imaging_date, "%Y%m%d")
|
||||
time_diff = abs((post_date - pre_date).days)
|
||||
except Exception:
|
||||
time_diff = None
|
||||
candidates.append(
|
||||
{
|
||||
"pre": {
|
||||
"id": pre_scene.id,
|
||||
"imaging_date": pre_radar.imaging_date,
|
||||
"satellite": pre_radar.satellite,
|
||||
"geo_path": _scene_analysis_path(pre_scene),
|
||||
"analysis_tif_path": _scene_analysis_path(pre_scene),
|
||||
},
|
||||
"post": {
|
||||
"id": post_scene.id,
|
||||
"imaging_date": post_radar.imaging_date,
|
||||
"satellite": post_radar.satellite,
|
||||
"geo_path": _scene_analysis_path(post_scene),
|
||||
"analysis_tif_path": _scene_analysis_path(post_scene),
|
||||
},
|
||||
"overlap_ratio": round(ratio, 4),
|
||||
"time_diff_days": time_diff,
|
||||
}
|
||||
)
|
||||
|
||||
candidates.sort(key=lambda item: item["overlap_ratio"], reverse=True)
|
||||
used_pre: set[int] = set()
|
||||
used_post: set[int] = set()
|
||||
pairs = []
|
||||
for candidate in candidates:
|
||||
pre_id = candidate["pre"]["id"]
|
||||
post_id = candidate["post"]["id"]
|
||||
if pre_id in used_pre or post_id in used_post:
|
||||
continue
|
||||
used_pre.add(pre_id)
|
||||
used_post.add(post_id)
|
||||
pairs.append(candidate)
|
||||
|
||||
pairs.sort(key=lambda item: item["overlap_ratio"], reverse=True)
|
||||
return {"pairs": pairs, "total": len(pairs)}
|
||||
|
||||
|
||||
async def search_disaster_pairs(req: Any, db: AsyncSession) -> dict[str, Any]:
|
||||
disaster_date = _parse_ymd(req.disaster_date, field="disaster_date")
|
||||
pre_window_days = max(1, int(getattr(req, "pre_window_days", 30) or 30))
|
||||
post_window_days = max(1, int(getattr(req, "post_window_days", 30) or 30))
|
||||
min_aoi_coverage_ratio = max(0.0, min(1.0, float(getattr(req, "min_aoi_coverage_ratio", 0.2) or 0.0)))
|
||||
min_pair_overlap_ratio = max(0.0, min(1.0, float(getattr(req, "min_pair_overlap_ratio", 0.3) or 0.0)))
|
||||
max_pairs = max(1, min(200, int(getattr(req, "max_pairs", 50) or 50)))
|
||||
|
||||
aoi_wkt, aoi_geojson, aoi_meta = _resolve_aoi_wkt_from_request(req)
|
||||
pre_start = disaster_date - timedelta(days=pre_window_days)
|
||||
pre_end = disaster_date - timedelta(days=1)
|
||||
post_start = disaster_date
|
||||
post_end = disaster_date + timedelta(days=post_window_days)
|
||||
|
||||
pre_pool = await _query_disaster_scene_pool(
|
||||
db=db,
|
||||
req=req,
|
||||
aoi_wkt=aoi_wkt,
|
||||
start_ymd=_format_ymd(pre_start),
|
||||
end_ymd=_format_ymd(pre_end),
|
||||
min_aoi_coverage_ratio=min_aoi_coverage_ratio,
|
||||
descending=True,
|
||||
)
|
||||
post_pool = await _query_disaster_scene_pool(
|
||||
db=db,
|
||||
req=req,
|
||||
aoi_wkt=aoi_wkt,
|
||||
start_ymd=_format_ymd(post_start),
|
||||
end_ymd=_format_ymd(post_end),
|
||||
min_aoi_coverage_ratio=min_aoi_coverage_ratio,
|
||||
descending=False,
|
||||
)
|
||||
|
||||
candidates: list[dict[str, Any]] = []
|
||||
require_same_polarization = bool(getattr(req, "require_same_polarization", True))
|
||||
require_same_imaging_mode = bool(getattr(req, "require_same_imaging_mode", False))
|
||||
total_window = max(1, pre_window_days + post_window_days)
|
||||
|
||||
for pre in pre_pool:
|
||||
pre_date = _parse_ymd(pre.get("imaging_date"), field="pre.imaging_date")
|
||||
for post in post_pool:
|
||||
if pre["id"] == post["id"]:
|
||||
continue
|
||||
if require_same_polarization and not _same_text(pre.get("polarization"), post.get("polarization")):
|
||||
continue
|
||||
if require_same_imaging_mode and not _same_text(pre.get("imaging_mode"), post.get("imaging_mode")):
|
||||
continue
|
||||
|
||||
post_date = _parse_ymd(post.get("imaging_date"), field="post.imaging_date")
|
||||
scene_overlap = _overlap_ratio(pre.get("coverage_polygon"), post.get("coverage_polygon"))
|
||||
if scene_overlap < min_pair_overlap_ratio:
|
||||
continue
|
||||
|
||||
pre_delta_days = max(0, (disaster_date - pre_date).days)
|
||||
post_delta_days = max(0, (post_date - disaster_date).days)
|
||||
time_score = max(0.0, 1.0 - ((pre_delta_days + post_delta_days) / total_window))
|
||||
aoi_score = min(_to_float(pre.get("aoi_coverage_ratio")), _to_float(post.get("aoi_coverage_ratio")))
|
||||
score = (scene_overlap * 0.45) + (aoi_score * 0.35) + (time_score * 0.20)
|
||||
candidates.append(
|
||||
{
|
||||
"pre": pre,
|
||||
"post": post,
|
||||
"overlap_ratio": round(scene_overlap, 4),
|
||||
"aoi_coverage_ratio": round(aoi_score, 4),
|
||||
"time_score": round(time_score, 4),
|
||||
"score": round(score, 4),
|
||||
"pre_delta_days": pre_delta_days,
|
||||
"post_delta_days": post_delta_days,
|
||||
"time_diff_days": max(0, (post_date - pre_date).days),
|
||||
"same_polarization": _same_text(pre.get("polarization"), post.get("polarization")),
|
||||
"same_imaging_mode": _same_text(pre.get("imaging_mode"), post.get("imaging_mode")),
|
||||
}
|
||||
)
|
||||
|
||||
candidates.sort(
|
||||
key=lambda item: (
|
||||
item["score"],
|
||||
item["overlap_ratio"],
|
||||
item["aoi_coverage_ratio"],
|
||||
-item["time_diff_days"],
|
||||
),
|
||||
reverse=True,
|
||||
)
|
||||
selected_pairs = candidates[:max_pairs]
|
||||
|
||||
warnings: list[str] = []
|
||||
if not pre_pool:
|
||||
warnings.append("No pre-disaster DONE scenes match the disaster AOI and time window")
|
||||
if not post_pool:
|
||||
warnings.append("No post-disaster DONE scenes match the disaster AOI and time window")
|
||||
if pre_pool and post_pool and not selected_pairs:
|
||||
warnings.append("Pre/post scene pools exist, but no pair meets the overlap/polarization constraints")
|
||||
|
||||
return {
|
||||
"disaster": {
|
||||
"name": getattr(req, "disaster_name", None),
|
||||
"date": _format_ymd(disaster_date),
|
||||
"pre_start": _format_ymd(pre_start),
|
||||
"pre_end": _format_ymd(pre_end),
|
||||
"post_start": _format_ymd(post_start),
|
||||
"post_end": _format_ymd(post_end),
|
||||
},
|
||||
"aoi": {
|
||||
**aoi_meta,
|
||||
"geojson": aoi_geojson,
|
||||
},
|
||||
"pre_pool": pre_pool,
|
||||
"post_pool": post_pool,
|
||||
"candidate_pairs": selected_pairs,
|
||||
"pairs": selected_pairs,
|
||||
"total": len(selected_pairs),
|
||||
"summary": {
|
||||
"pre_pool_count": len(pre_pool),
|
||||
"post_pool_count": len(post_pool),
|
||||
"candidate_count": len(selected_pairs),
|
||||
"min_aoi_coverage_ratio": min_aoi_coverage_ratio,
|
||||
"min_pair_overlap_ratio": min_pair_overlap_ratio,
|
||||
},
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
|
||||
def _open_envi_rasterio(path: str):
|
||||
import rasterio
|
||||
|
||||
normalized_path = path.replace("\\", "/")
|
||||
try:
|
||||
return rasterio.open(normalized_path)
|
||||
except Exception:
|
||||
pass
|
||||
for ext in (".bin", ".img", ".tif", ".tiff"):
|
||||
try:
|
||||
return rasterio.open(normalized_path + ext)
|
||||
except Exception:
|
||||
pass
|
||||
raise FileNotFoundError(f"Raster file cannot be opened: {path}")
|
||||
|
||||
|
||||
def _raster_to_png_bytes(path: str, colormap: dict[int, tuple[int, int, int, int]]) -> tuple[bytes, list[float]]:
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
with _open_envi_rasterio(path) as ds:
|
||||
data = ds.read(1)
|
||||
bounds = ds.bounds
|
||||
geo_bounds = [bounds.bottom, bounds.left, bounds.top, bounds.right]
|
||||
|
||||
rgba = np.zeros((data.shape[0], data.shape[1], 4), dtype=np.uint8)
|
||||
for value, color in colormap.items():
|
||||
rgba[data == value] = color
|
||||
image = Image.fromarray(rgba, "RGBA")
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return buffer.getvalue(), geo_bounds
|
||||
|
||||
|
||||
def _geo_raster_to_png_bytes(path: str) -> tuple[bytes, list[float]]:
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
with _open_envi_rasterio(path) as ds:
|
||||
data = ds.read(1).astype("float32")
|
||||
nodata = ds.nodata
|
||||
bounds = ds.bounds
|
||||
geo_bounds = [bounds.bottom, bounds.left, bounds.top, bounds.right]
|
||||
|
||||
if nodata is not None:
|
||||
nodata_mask = (data == nodata) | ~np.isfinite(data)
|
||||
else:
|
||||
nodata_mask = ~np.isfinite(data)
|
||||
|
||||
valid = data[~nodata_mask]
|
||||
if valid.size == 0:
|
||||
normalized = np.zeros_like(data, dtype=np.uint8)
|
||||
else:
|
||||
p2, p98 = np.percentile(valid, 2), np.percentile(valid, 98)
|
||||
clipped = np.clip(data, p2, p98)
|
||||
normalized = ((clipped - p2) / max(p98 - p2, 1e-9) * 255).astype(np.uint8)
|
||||
|
||||
rgba = np.stack([normalized, normalized, normalized, np.full_like(normalized, 200)], axis=-1)
|
||||
rgba[nodata_mask, 3] = 0
|
||||
image = Image.fromarray(rgba, "RGBA")
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return buffer.getvalue(), geo_bounds
|
||||
|
||||
|
||||
_FLOOD_COLORMAP = {
|
||||
1: (24, 144, 255, 200),
|
||||
2: (255, 77, 79, 220),
|
||||
3: (250, 173, 20, 180),
|
||||
4: (80, 80, 80, 80),
|
||||
}
|
||||
|
||||
|
||||
async def get_flood_detection_preview(detection_id: int, layer: str, db: AsyncSession):
|
||||
normalized_layer = layer.strip().lower()
|
||||
if normalized_layer == "classified":
|
||||
detection = await db.get(FloodDetectionORM, detection_id)
|
||||
if not detection or not detection.classified_path:
|
||||
raise HTTPException(status_code=404, detail="Classified result not found")
|
||||
path = detection.classified_path.replace("\\", "/")
|
||||
png_bytes, geo_bounds = await _render_classified_preview(path)
|
||||
return JSONResponse(
|
||||
{
|
||||
"image_b64": base64.b64encode(png_bytes).decode(),
|
||||
"bounds": geo_bounds,
|
||||
"legend": {
|
||||
"stable_water": "#1890ff",
|
||||
"flood": "#ff4d4f",
|
||||
"high_backscatter": "#faad14",
|
||||
"non_water": "#505050",
|
||||
},
|
||||
}
|
||||
)
|
||||
if normalized_layer in ("pre", "post"):
|
||||
return await _get_scene_preview_for_detection(detection_id, normalized_layer, db)
|
||||
raise HTTPException(status_code=404, detail=f"Unsupported preview layer: {layer}")
|
||||
|
||||
|
||||
async def _render_classified_preview(path: str) -> tuple[bytes, list[float]]:
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail="Requested file does not exist")
|
||||
try:
|
||||
return await asyncio.to_thread(_raster_to_png_bytes, path, _FLOOD_COLORMAP)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Render failed: {exc}") from exc
|
||||
|
||||
|
||||
async def _get_scene_preview_for_detection(detection_id: int, layer: str, db: AsyncSession):
|
||||
detection = await db.get(FloodDetectionORM, detection_id)
|
||||
if not detection:
|
||||
raise HTTPException(status_code=404, detail="Flood detection not found")
|
||||
scene_id = detection.pre_scene_id if layer == "pre" else detection.post_scene_id
|
||||
scene = await db.get(SARSceneGeoORM, scene_id)
|
||||
scene_path = _scene_analysis_path(scene)
|
||||
if not scene_path:
|
||||
raise HTTPException(status_code=404, detail="Scene analysis-ready GeoTIFF not found")
|
||||
path = scene_path.replace("\\", "/")
|
||||
if not os.path.isfile(path):
|
||||
raise HTTPException(status_code=404, detail="Requested file does not exist")
|
||||
try:
|
||||
png_bytes, geo_bounds = await asyncio.to_thread(_geo_raster_to_png_bytes, path)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Render failed: {exc}") from exc
|
||||
return JSONResponse({"image_b64": base64.b64encode(png_bytes).decode(), "bounds": geo_bounds})
|
||||
|
||||
|
||||
async def get_water_extraction_preview(extraction_id: int, db: AsyncSession) -> dict[str, Any]:
|
||||
detection = await db.get(WaterExtractionORM, extraction_id)
|
||||
if not detection:
|
||||
detection = await db.get(WaterDetectionORM, extraction_id)
|
||||
if not detection:
|
||||
raise HTTPException(status_code=404, detail=f"Water extraction id={extraction_id} not found")
|
||||
if not detection.output_path or not os.path.isfile(detection.output_path):
|
||||
raise HTTPException(status_code=404, detail="Output file does not exist")
|
||||
|
||||
import numpy as np
|
||||
import rasterio
|
||||
from PIL import Image
|
||||
|
||||
with rasterio.open(detection.output_path) as src:
|
||||
data = src.read(1)
|
||||
transform = src.transform
|
||||
height, width = data.shape
|
||||
min_lon = transform.c
|
||||
max_lon = transform.c + width * transform.a
|
||||
max_lat = transform.f
|
||||
min_lat = transform.f + height * transform.e
|
||||
|
||||
rgba = np.zeros((data.shape[0], data.shape[1], 4), dtype=np.uint8)
|
||||
rgba[data > 0] = [24, 144, 255, 160]
|
||||
|
||||
image = Image.fromarray(rgba, "RGBA")
|
||||
max_dim = 1024
|
||||
if max(width, height) > max_dim:
|
||||
ratio = max_dim / max(width, height)
|
||||
image = image.resize((int(width * ratio), int(height * ratio)), Image.NEAREST)
|
||||
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return {
|
||||
"png_base64": base64.b64encode(buffer.getvalue()).decode(),
|
||||
"bounds": {
|
||||
"min_lon": min_lon,
|
||||
"min_lat": min_lat,
|
||||
"max_lon": max_lon,
|
||||
"max_lat": max_lat,
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user