feat: embed gf3 water extraction workflow
This commit is contained in:
@@ -134,6 +134,72 @@ def _scene_analysis_path(scene: SARSceneGeoORM | None) -> str | 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)
|
||||
@@ -393,22 +459,44 @@ async def list_scenes(limit: int, offset: int, db: AsyncSession) -> dict[str, An
|
||||
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")
|
||||
input_path = _scene_analysis_path(scene)
|
||||
if not input_path:
|
||||
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")
|
||||
|
||||
if not input_path:
|
||||
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=getattr(req, "processor", None) or "otsu",
|
||||
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)
|
||||
@@ -421,7 +509,13 @@ async def submit_water_extraction(req: Any, db: AsyncSession) -> dict[str, Any]:
|
||||
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},
|
||||
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)
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
"""GF-3 HH/HV water extraction adapter for the flood-analysis job chain."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..config import settings
|
||||
from ..processors.gf3_water import WaterExtractionConfig, run_water_extraction
|
||||
|
||||
|
||||
GF3_HH_HV_PROCESSOR = "gf3_hh_hv"
|
||||
|
||||
|
||||
def _existing_path(value: str | os.PathLike[str] | None, *, label: str) -> Path:
|
||||
if not value:
|
||||
raise ValueError(f"{label} is required")
|
||||
path = Path(str(value))
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"{label} does not exist: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def _optional_existing_path(value: str | os.PathLike[str] | None, *, label: str) -> Path | None:
|
||||
if not value:
|
||||
return None
|
||||
path = Path(str(value))
|
||||
if not path.exists():
|
||||
raise FileNotFoundError(f"{label} does not exist: {path}")
|
||||
return path
|
||||
|
||||
|
||||
def _as_bool(value: Any, default: bool) -> bool:
|
||||
if value is None:
|
||||
return default
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
text = str(value).strip().lower()
|
||||
if text in {"1", "true", "yes", "y", "on"}:
|
||||
return True
|
||||
if text in {"0", "false", "no", "n", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _numeric_param(params: dict[str, Any], name: str, default: Any, cast: type) -> Any:
|
||||
value = params.get(name, default)
|
||||
if value is None or value == "":
|
||||
return default
|
||||
try:
|
||||
return cast(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _path_param_list(params: dict[str, Any], name: str) -> list[Path]:
|
||||
raw = params.get(name) or []
|
||||
if isinstance(raw, (str, os.PathLike)):
|
||||
raw = [raw]
|
||||
paths: list[Path] = []
|
||||
for item in raw:
|
||||
if not item:
|
||||
continue
|
||||
paths.append(_existing_path(item, label=name))
|
||||
return paths
|
||||
|
||||
|
||||
def _first_existing(*paths: Path) -> str | None:
|
||||
for path in paths:
|
||||
if path.exists():
|
||||
return str(path)
|
||||
return None
|
||||
|
||||
|
||||
def _read_metadata(path: Path) -> dict[str, Any]:
|
||||
try:
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"GF3 water metadata is not valid JSON: {path}") from exc
|
||||
|
||||
|
||||
def _pixel_area_km2(transform: Any, crs: Any, bounds: Any) -> float:
|
||||
px_w = abs(float(transform.a))
|
||||
px_h = abs(float(transform.e))
|
||||
if crs and getattr(crs, "is_geographic", False):
|
||||
lat_center = (float(bounds.top) + float(bounds.bottom)) / 2.0
|
||||
px_w_m = px_w * math.cos(math.radians(lat_center)) * 111_320.0
|
||||
px_h_m = px_h * 111_320.0
|
||||
else:
|
||||
px_w_m, px_h_m = px_w, px_h
|
||||
return max(0.0, (px_w_m * px_h_m) / 1_000_000.0)
|
||||
|
||||
|
||||
def _water_area_km2(mask_path: str | None, *, water_pixel_count: int | None = None) -> float | None:
|
||||
if not mask_path or not os.path.isfile(mask_path):
|
||||
return None
|
||||
try:
|
||||
import rasterio
|
||||
|
||||
with rasterio.open(mask_path) as src:
|
||||
data = src.read(1)
|
||||
pixel_count = int((data > 0).sum()) if water_pixel_count is None else int(water_pixel_count)
|
||||
return round(pixel_count * _pixel_area_km2(src.transform, src.crs, src.bounds), 4)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _vector_runtime_available() -> bool:
|
||||
try:
|
||||
import fiona # noqa: F401
|
||||
import rasterio.features # noqa: F401
|
||||
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def run_gf3_hh_hv_water_extraction(
|
||||
*,
|
||||
hh_path: str,
|
||||
hv_path: str,
|
||||
output_dir: str,
|
||||
job_id: str | None = None,
|
||||
params: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Run the embedded GF-3 HH/HV water extractor and normalize outputs."""
|
||||
params = dict(params or {})
|
||||
out_dir = Path(output_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
hh = _existing_path(hh_path, label="HH input")
|
||||
hv = _existing_path(hv_path, label="HV input")
|
||||
dltb_cache_dir = _optional_existing_path(
|
||||
params.get("dltb_cache_dir") or settings.GF3_WATER_DLTB_CACHE_DIR,
|
||||
label="GF3_WATER_DLTB_CACHE_DIR",
|
||||
)
|
||||
dem = _optional_existing_path(
|
||||
params.get("dem") or params.get("dem_path") or settings.GF3_WATER_DEM_PATH,
|
||||
label="GF3_WATER_DEM_PATH",
|
||||
)
|
||||
|
||||
cartographic_water = _as_bool(
|
||||
params.get("cartographic_water"),
|
||||
bool(settings.GF3_WATER_DEFAULT_CARTOGRAPHIC),
|
||||
)
|
||||
out_vector = _as_bool(params.get("out_vector"), bool(settings.GF3_WATER_DEFAULT_OUT_VECTOR))
|
||||
vector_runtime_available = _vector_runtime_available()
|
||||
vector_output_enabled = out_vector and vector_runtime_available
|
||||
|
||||
config = WaterExtractionConfig(
|
||||
hh=hh,
|
||||
hv=hv,
|
||||
out_dir=out_dir,
|
||||
dem=dem,
|
||||
dltb_cache_dir=dltb_cache_dir,
|
||||
dltb_mode=str(params.get("dltb_mode") or "soft"),
|
||||
water_vector=_path_param_list(params, "water_vector"),
|
||||
paddy_vector=_path_param_list(params, "paddy_vector"),
|
||||
threshold_method=str(params.get("threshold_method") or "percentile"),
|
||||
score_percentile=_numeric_param(params, "score_percentile", 95.0, float),
|
||||
hv_percentile=_numeric_param(params, "hv_percentile", 20.0, float),
|
||||
candidate_score_percentile=_numeric_param(params, "candidate_score_percentile", 90.0, float),
|
||||
candidate_hv_percentile=_numeric_param(params, "candidate_hv_percentile", 50.0, float),
|
||||
close_pixels=_numeric_param(params, "close_pixels", 2, int),
|
||||
open_pixels=_numeric_param(params, "open_pixels", 1, int),
|
||||
fill_hole_pixels=_numeric_param(params, "fill_hole_pixels", 2048, int),
|
||||
min_component_pixels=_numeric_param(params, "min_component_pixels", 4096, int),
|
||||
candidate_open_pixels=_numeric_param(params, "candidate_open_pixels", 1, int),
|
||||
cartographic_water=cartographic_water,
|
||||
cartographic_close_pixels=_numeric_param(params, "cartographic_close_pixels", 4, int),
|
||||
cartographic_fill_hole_pixels=_numeric_param(params, "cartographic_fill_hole_pixels", 20000, int),
|
||||
cartographic_min_component_pixels=_numeric_param(params, "cartographic_min_component_pixels", 4096, int),
|
||||
out_vector_shp_dir=out_dir / "shp" if vector_output_enabled else None,
|
||||
min_polygon_area_m2=_numeric_param(params, "min_polygon_area_m2", 50000.0, float),
|
||||
simplify_meters=_numeric_param(params, "simplify_meters", 3.0, float),
|
||||
smooth_meters=_numeric_param(params, "smooth_meters", 5.0, float),
|
||||
min_hole_area_m2=_numeric_param(params, "min_hole_area_m2", 5000.0, float),
|
||||
)
|
||||
|
||||
exit_code = run_water_extraction(config)
|
||||
metadata_path = out_dir / "metadata.json"
|
||||
metadata = _read_metadata(metadata_path)
|
||||
if exit_code != 0:
|
||||
return {
|
||||
"ok": False,
|
||||
"processor": GF3_HH_HV_PROCESSOR,
|
||||
"error": f"GF3 HH/HV water extraction failed with exit code {exit_code}",
|
||||
"metadata_json": metadata,
|
||||
}
|
||||
|
||||
output_path = _first_existing(
|
||||
out_dir / "cartographic_water.tif",
|
||||
out_dir / "water_mask.tif",
|
||||
out_dir / "classified_water.tif",
|
||||
)
|
||||
preview_path = _first_existing(out_dir / "preview_overlay.png", out_dir / "classified_preview.png")
|
||||
vector_path = _first_existing(out_dir / "shp" / "cartographic_water.shp", out_dir / "water_products.gpkg")
|
||||
water_pixels = (
|
||||
metadata.get("cartographic_water_pixels")
|
||||
if metadata.get("cartographic_water_enabled")
|
||||
else metadata.get("water_pixels")
|
||||
)
|
||||
if water_pixels is None:
|
||||
water_pixels = metadata.get("water_pixels")
|
||||
water_pixel_count = int(water_pixels or 0)
|
||||
area_km2 = _water_area_km2(output_path, water_pixel_count=water_pixel_count)
|
||||
|
||||
normalized_metadata = {
|
||||
**metadata,
|
||||
"processor": GF3_HH_HV_PROCESSOR,
|
||||
"job_id": job_id,
|
||||
"metadata_path": str(metadata_path),
|
||||
"output_path": output_path,
|
||||
"preview_path": preview_path,
|
||||
"vector_path": vector_path,
|
||||
"input_assets": {
|
||||
"hh": str(hh),
|
||||
"hv": str(hv),
|
||||
"dem": str(dem) if dem else None,
|
||||
"dltb_cache_dir": str(dltb_cache_dir) if dltb_cache_dir else None,
|
||||
},
|
||||
"runtime": {
|
||||
"vector_requested": out_vector,
|
||||
"vector_runtime_available": vector_runtime_available,
|
||||
"vector_output_enabled": vector_output_enabled,
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"ok": True,
|
||||
"processor": GF3_HH_HV_PROCESSOR,
|
||||
"output_path": output_path,
|
||||
"preview_path": preview_path,
|
||||
"vector_path": vector_path,
|
||||
"water_area_km2": area_km2,
|
||||
"water_pixel_count": water_pixel_count,
|
||||
"threshold_value": metadata.get("score_threshold"),
|
||||
"metadata_json": normalized_metadata,
|
||||
}
|
||||
@@ -3490,6 +3490,7 @@ async def _handle_flood_detection(job: SystemJobORM) -> None:
|
||||
|
||||
async def _handle_water_detect(job: SystemJobORM) -> None:
|
||||
"""水体检测 job handler(Otsu + DEM + 形态学 + 连通分量)。"""
|
||||
from .gf3_water_extraction_service import GF3_HH_HV_PROCESSOR, run_gf3_hh_hv_water_extraction
|
||||
from .water_extraction_service import run_otsu_water_extraction
|
||||
|
||||
payload = job.payload or {}
|
||||
@@ -3508,6 +3509,15 @@ async def _handle_water_detect(job: SystemJobORM) -> None:
|
||||
model_name = "WaterExtractionORM" if use_extraction_table else "WaterDetectionORM"
|
||||
raise ValueError(f"{model_name} id={record_id} 不存在")
|
||||
input_path = det.input_path
|
||||
metadata = det.metadata_json if use_extraction_table and isinstance(det.metadata_json, dict) else {}
|
||||
processor = str(payload.get("processor") or getattr(det, "processor", None) or "otsu").strip().lower()
|
||||
if processor in {"gf3_water", "gf3_water_hh_hv", "hh_hv"}:
|
||||
processor = GF3_HH_HV_PROCESSOR
|
||||
input_assets = metadata.get("input_assets") if isinstance(metadata.get("input_assets"), dict) else {}
|
||||
processor_params = dict(metadata.get("processor_params") or {})
|
||||
processor_params.update(dict(payload.get("processor_params") or {}))
|
||||
hh_path = payload.get("hh_path") or input_assets.get("hh")
|
||||
hv_path = payload.get("hv_path") or input_assets.get("hv")
|
||||
det.status = "RUNNING"
|
||||
if use_extraction_table and hasattr(det, "task_id"):
|
||||
det.task_id = job.task_id
|
||||
@@ -3518,17 +3528,28 @@ async def _handle_water_detect(job: SystemJobORM) -> None:
|
||||
mirror.task_id = job.task_id
|
||||
await db.commit()
|
||||
|
||||
if not input_path:
|
||||
if processor == GF3_HH_HV_PROCESSOR:
|
||||
if not hh_path or not hv_path:
|
||||
raise ValueError("GF3 HH/HV water extraction requires hh_path and hv_path")
|
||||
elif not input_path:
|
||||
raise ValueError("水体检测缺少输入路径 input_path")
|
||||
|
||||
output_name = f"water_extraction_{record_id}" if use_extraction_table else f"water_detect_{record_id}"
|
||||
output_root = settings.WATER_RESULTS_DIR or os.path.join(settings.BACKEND_DIR, "water_results")
|
||||
output_dir = os.path.join(output_root, output_name)
|
||||
output_dir = os.path.join(output_root, processor, output_name) if use_extraction_table else os.path.join(output_root, output_name)
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
await task_service.update_task(job.task_id, progress=10, message="启动水体检测算法...")
|
||||
|
||||
def _run() -> Dict[str, Any]:
|
||||
if processor == GF3_HH_HV_PROCESSOR:
|
||||
return run_gf3_hh_hv_water_extraction(
|
||||
hh_path=hh_path,
|
||||
hv_path=hv_path,
|
||||
output_dir=output_dir,
|
||||
job_id=job.job_id,
|
||||
params=processor_params,
|
||||
)
|
||||
return run_otsu_water_extraction(
|
||||
input_path=input_path,
|
||||
output_dir=output_dir,
|
||||
@@ -3557,16 +3578,24 @@ async def _handle_water_detect(job: SystemJobORM) -> None:
|
||||
if det:
|
||||
if result.get("ok"):
|
||||
det.output_path = result.get("output_path")
|
||||
if hasattr(det, "preview_path"):
|
||||
det.preview_path = result.get("preview_path")
|
||||
if hasattr(det, "vector_path"):
|
||||
det.vector_path = result.get("vector_path")
|
||||
det.water_area_km2 = result.get("water_area_km2")
|
||||
det.water_pixel_count = result.get("water_pixel_count")
|
||||
if use_extraction_table:
|
||||
det.processor = result.get("processor") or det.processor or "otsu"
|
||||
det.threshold_value = result.get("threshold_value")
|
||||
det.metadata_json = {
|
||||
"legacy_otsu_threshold_db": result.get("otsu_threshold_db"),
|
||||
"value_transform": result.get("value_transform"),
|
||||
"job_id": job.job_id,
|
||||
}
|
||||
result_metadata = result.get("metadata_json")
|
||||
if isinstance(result_metadata, dict):
|
||||
det.metadata_json = result_metadata
|
||||
else:
|
||||
det.metadata_json = {
|
||||
"legacy_otsu_threshold_db": result.get("otsu_threshold_db"),
|
||||
"value_transform": result.get("value_transform"),
|
||||
"job_id": job.job_id,
|
||||
}
|
||||
else:
|
||||
det.otsu_threshold_db = result.get("otsu_threshold_db")
|
||||
det.status = "DONE"
|
||||
@@ -3578,6 +3607,8 @@ async def _handle_water_detect(job: SystemJobORM) -> None:
|
||||
mirror = await db.get(WaterExtractionORM, int(record_id))
|
||||
if mirror:
|
||||
mirror.output_path = det.output_path
|
||||
mirror.preview_path = result.get("preview_path")
|
||||
mirror.vector_path = result.get("vector_path")
|
||||
mirror.water_area_km2 = det.water_area_km2
|
||||
mirror.water_pixel_count = det.water_pixel_count
|
||||
mirror.threshold_value = result.get("threshold_value") or result.get("otsu_threshold_db")
|
||||
|
||||
Reference in New Issue
Block a user