Files
insar-management-system-v2/backend/app/services/flood_analysis_service.py
T

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,
},
}