1062 lines
42 KiB
Python
1062 lines
42 KiB
Python
"""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 _normalize_processor(value: Any) -> str:
|
|
processor = str(value or "").strip().lower()
|
|
if processor in {"gf3_water", "gf3_water_hh_hv", "gf3_hh_hv", "hh_hv"}:
|
|
return "gf3_hh_hv"
|
|
return processor or "otsu"
|
|
|
|
|
|
def _metadata_dict(value: Any) -> dict[str, Any]:
|
|
if isinstance(value, dict):
|
|
return value
|
|
if isinstance(value, str) and value.strip():
|
|
try:
|
|
parsed = json.loads(value)
|
|
return parsed if isinstance(parsed, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
return {}
|
|
|
|
|
|
def _find_standard_asset_by_pol(metadata: dict[str, Any], polarization: str) -> str | None:
|
|
target = str(polarization or "").upper()
|
|
candidates = []
|
|
candidates.extend(metadata.get("standard_assets") or [])
|
|
nested = metadata.get("metadata")
|
|
if isinstance(nested, dict):
|
|
candidates.extend(nested.get("standard_assets") or [])
|
|
for asset in candidates:
|
|
if not isinstance(asset, dict):
|
|
continue
|
|
if str(asset.get("polarization") or "").upper() != target:
|
|
continue
|
|
for key in ("source_native", "path"):
|
|
path = str(asset.get(key) or "").strip()
|
|
if path:
|
|
return path
|
|
return None
|
|
|
|
|
|
def _resolve_gf3_hh_hv_inputs(
|
|
*,
|
|
req: Any,
|
|
scene: SARSceneGeoORM | None,
|
|
radar: RadarDataORM | None,
|
|
) -> tuple[str | None, str | None, dict[str, Any]]:
|
|
hh_path = str(getattr(req, "hh_path", "") or "").strip() or None
|
|
hv_path = str(getattr(req, "hv_path", "") or "").strip() or None
|
|
resolution: dict[str, Any] = {"source": "request" if hh_path or hv_path else "metadata"}
|
|
if hh_path and hv_path:
|
|
return hh_path, hv_path, resolution
|
|
|
|
scene_meta = _metadata_dict(scene.analysis_metadata_json if scene else None)
|
|
radar_meta = _metadata_dict(radar.metadata_json if radar else None)
|
|
for metadata in (scene_meta, radar_meta):
|
|
hh_path = hh_path or _find_standard_asset_by_pol(metadata, "HH")
|
|
hv_path = hv_path or _find_standard_asset_by_pol(metadata, "HV")
|
|
resolution.update(
|
|
{
|
|
"scene_metadata_used": bool(scene_meta),
|
|
"radar_metadata_used": bool(radar_meta),
|
|
"hh_auto_resolved": bool(hh_path),
|
|
"hv_auto_resolved": bool(hv_path),
|
|
}
|
|
)
|
|
return hh_path, hv_path, resolution
|
|
|
|
|
|
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
|
|
processor = _normalize_processor(getattr(req, "processor", None))
|
|
processor_params = dict(getattr(req, "processor_params", None) or {})
|
|
scene: SARSceneGeoORM | None = None
|
|
radar: RadarDataORM | None = None
|
|
|
|
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")
|
|
if scene.radar_data_id:
|
|
radar = await db.get(RadarDataORM, scene.radar_data_id)
|
|
if processor == "gf3_hh_hv":
|
|
input_path = input_path or _scene_analysis_path(scene)
|
|
else:
|
|
input_path = _scene_analysis_path(scene)
|
|
if processor != "gf3_hh_hv" and not input_path:
|
|
raise HTTPException(status_code=400, detail="Scene has no analysis-ready GeoTIFF")
|
|
|
|
hh_path = None
|
|
hv_path = None
|
|
input_resolution: dict[str, Any] = {}
|
|
if processor == "gf3_hh_hv":
|
|
hh_path, hv_path, input_resolution = _resolve_gf3_hh_hv_inputs(req=req, scene=scene, radar=radar)
|
|
if not hh_path or not hv_path:
|
|
raise HTTPException(status_code=400, detail="GF3 HH/HV water extraction requires hh_path and hv_path")
|
|
input_path = input_path or hh_path
|
|
elif not input_path:
|
|
raise HTTPException(status_code=400, detail="scene_id or input_path is required")
|
|
|
|
extraction = WaterExtractionORM(
|
|
scene_id=scene_id,
|
|
processor=processor,
|
|
input_path=input_path,
|
|
metadata_json={
|
|
"processor_params": processor_params,
|
|
"input_assets": {"hh": hh_path, "hv": hv_path} if processor == "gf3_hh_hv" else {},
|
|
"input_resolution": input_resolution,
|
|
},
|
|
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,
|
|
"hh_path": hh_path,
|
|
"hv_path": hv_path,
|
|
"processor_params": processor_params,
|
|
},
|
|
)
|
|
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,
|
|
},
|
|
}
|