"""Water body monitoring v2 router — SARscape-based pipeline.""" from __future__ import annotations import os import re as _re from datetime import datetime from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel, Field from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload from ..config import settings from ..database import get_db from ..models import AuthUserORM, RadarDataORM, SARSceneGeoORM, FloodDetectionORM, WaterDetectionORM, GF3ProcessingORM from ..services.job_handlers import JOB_TYPE_WATER_GEOCODE, JOB_TYPE_WATER_FLOOD, JOB_TYPE_WATER_DETECT, JOB_TYPE_GF3_PROCESS from ..services.job_queue_service import job_queue_service from ..services.task_service import task_service from .dependencies import _require_admin, _get_current_user router = APIRouter() _WATER_JOB_MAX_ATTEMPTS = 3 # --------------------------------------------------------------------------- # Request models # --------------------------------------------------------------------------- class GeocodeRequest(BaseModel): radar_data_id: int = Field(..., description="RadarDataORM 主键") class FloodDetectRequest(BaseModel): pre_scene_id: int = Field(..., description="灾前 SARSceneGeoORM 主键") post_scene_id: int = Field(..., description="灾后 SARSceneGeoORM 主键") refine: bool = Field(default=False, description="是否启用 MRF 精化") class WaterPairRequest(BaseModel): pre_start: Optional[str] = Field(default=None, description="灾前开始日期 YYYYMMDD") pre_end: Optional[str] = Field(default=None, description="灾前结束日期 YYYYMMDD") post_start: Optional[str] = Field(default=None, description="灾后开始日期 YYYYMMDD") post_end: Optional[str] = Field(default=None, description="灾后结束日期 YYYYMMDD") overlap_threshold: float = Field(default=0.3, ge=0.0, le=1.0, description="最小重叠比例") # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _overlap_ratio(poly_a, poly_b) -> float: """计算两个 polygon 的重叠比例(相对于较小的那个)。 支持 GeoJSON dict 或原始坐标数组 [[lon,lat], ...] 两种格式。 """ try: from shapely.geometry import shape, Polygon def _to_geom(poly): if isinstance(poly, str): import json poly = json.loads(poly) # 原始坐标数组格式:[[lon, lat], ...] if isinstance(poly, list): return Polygon(poly) # GeoJSON dict 格式 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 inter = a.intersection(b).area smaller = min(a.area, b.area) return inter / smaller if smaller > 0 else 0.0 except Exception: return 0.0 async def _queue_water_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=_WATER_JOB_MAX_ATTEMPTS, ) return {"task_id": task_id, "job_id": job_id, "job_type": job_type, "message": "Job queued."} except ValueError as exc: msg = str(exc) raise HTTPException(status_code=409 if "冲突" in msg else 400, detail=msg) from exc # --------------------------------------------------------------------------- # Endpoints # --------------------------------------------------------------------------- @router.post("/water/geocode", status_code=202) async def submit_geocode( req: GeocodeRequest, db: AsyncSession = Depends(get_db), admin_user: AuthUserORM = Depends(_require_admin), ): """提交单景 SAR 地理编码任务(多视 + 地理编码 + 辐射定标)。""" 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} 不存在") 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="该场景已有进行中的地理编码任务") 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() try: return await _queue_water_job( job_type=JOB_TYPE_WATER_GEOCODE, task_type=f"WATER_GEOCODE_{scene_id}", task_name=f"水体地理编码 radar_id={req.radar_data_id}", payload={"scene_id": scene_id, "radar_data_id": req.radar_data_id}, ) except HTTPException: # job 入队失败,回滚 scene 状态避免卡在 PENDING async with db.begin(): s = await db.get(SARSceneGeoORM, scene_id) if s and s.status == "PENDING": s.status = "FAILED" s.error_msg = "任务入队失败" raise @router.post("/water/scenes/{scene_id}/reset", status_code=200) async def reset_scene_status( scene_id: int, db: AsyncSession = Depends(get_db), admin_user: AuthUserORM = Depends(_require_admin), ): """将卡住的场景状态重置为 FAILED,允许重新提交。""" scene = await db.get(SARSceneGeoORM, scene_id) if not scene: raise HTTPException(status_code=404, detail=f"场景 id={scene_id} 不存在") if scene.status not in ("PENDING", "RUNNING"): raise HTTPException(status_code=400, detail=f"场景当前状态为 {scene.status},无需重置") scene.status = "FAILED" scene.error_msg = "手动重置(任务已取消)" await db.commit() return {"id": scene_id, "status": "FAILED", "message": "已重置"} @router.get("/water/scenes/done-ids") async def list_done_scene_radar_ids( db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_get_current_user), ): """返回所有 status=DONE 的场景对应的 radar_data_id 列表,用于前端标注。""" result = await db.execute( select(SARSceneGeoORM.radar_data_id).where(SARSceneGeoORM.status == "DONE") ) return {"ids": [row for (row,) in result.all()]} @router.get("/water/scenes/active-ids") async def list_active_scene_radar_ids( db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_get_current_user), ): """返回所有 status=PENDING/RUNNING 的场景对应的 radar_data_id 列表,用于前端标注。""" result = await db.execute( select(SARSceneGeoORM.radar_data_id).where( SARSceneGeoORM.status.in_(["PENDING", "RUNNING"]) ) ) return {"ids": [row for (row,) in result.all()]} @router.get("/water/scenes") async def list_scenes( limit: int = 20, offset: int = 0, db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_get_current_user), ): """列出所有单景地理编码记录(分页)。""" from sqlalchemy import func 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) ) rows = result.all() items = [] for scene, radar in rows: items.append({ "id": scene.id, "radar_data_id": scene.radar_data_id, "satellite": radar.satellite, "imaging_date": radar.imaging_date, "geo_path": scene.geo_path, "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} @router.delete("/water/scenes/cleanup", status_code=200) async def cleanup_failed_scenes( db: AsyncSession = Depends(get_db), admin_user: AuthUserORM = Depends(_require_admin), ): """删除所有 FAILED 状态的场景记录。""" from sqlalchemy import delete as sql_delete result = await db.execute( sql_delete(SARSceneGeoORM).where(SARSceneGeoORM.status == "FAILED") ) await db.commit() return {"deleted": result.rowcount} @router.post("/water/flood-detect", status_code=202) async def submit_flood_detect( req: FloodDetectRequest, db: AsyncSession = Depends(get_db), admin_user: AuthUserORM = Depends(_require_admin), ): """提交洪涝检测任务(灾前 + 灾后两景配对)。""" pre = await db.get(SARSceneGeoORM, req.pre_scene_id) post = await db.get(SARSceneGeoORM, req.post_scene_id) if not pre: raise HTTPException(status_code=404, detail=f"灾前场景 id={req.pre_scene_id} 不存在") if not post: raise HTTPException(status_code=404, detail=f"灾后场景 id={req.post_scene_id} 不存在") if pre.status != "DONE": raise HTTPException(status_code=400, detail=f"灾前场景尚未完成地理编码 (status={pre.status})") if post.status != "DONE": raise HTTPException(status_code=400, detail=f"灾后场景尚未完成地理编码 (status={post.status})") 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) ) det = result.scalar_one_or_none() if det and det.status in ("PENDING", "RUNNING"): raise HTTPException(status_code=409, detail="该配对已有进行中的洪涝检测任务") if not det: det = FloodDetectionORM( pre_scene_id=req.pre_scene_id, post_scene_id=req.post_scene_id, status="PENDING", ) db.add(det) await db.flush() else: det.status = "PENDING" det.error_msg = None await db.flush() det_id = det.id await db.commit() try: return await _queue_water_job( job_type=JOB_TYPE_WATER_FLOOD, task_type=f"WATER_FLOOD_{det_id}", task_name=f"洪涝检测 pre={req.pre_scene_id} post={req.post_scene_id}", payload={"detection_id": det_id, "refine": req.refine}, ) except HTTPException: async with db.begin(): d = await db.get(FloodDetectionORM, det_id) if d and d.status == "PENDING": d.status = "FAILED" d.error_msg = "任务入队失败" raise @router.get("/water/flood-events") async def list_flood_events( db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_get_current_user), ): """列出所有洪涝检测结果。""" 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()) ) dets = result.scalars().all() items = [] for det in dets: pre_rd = det.pre_scene.radar_data if det.pre_scene else None post_rd = det.post_scene.radar_data if det.post_scene else None items.append({ "id": det.id, "pre_scene_id": det.pre_scene_id, "post_scene_id": det.post_scene_id, "pre_imaging_date": pre_rd.imaging_date if pre_rd else None, "post_imaging_date": post_rd.imaging_date if post_rd else None, "pre_satellite": pre_rd.satellite if pre_rd else None, "post_satellite": post_rd.satellite if post_rd else None, "pre_geo_path": det.pre_scene.geo_path if det.pre_scene else None, "post_geo_path": det.post_scene.geo_path if det.post_scene else None, "classified_path": det.classified_path, "flood_area_km2": det.flood_area_km2, "stable_water_area_km2": det.stable_water_area_km2, "status": det.status, "error_msg": det.error_msg, "created_at": det.created_at.isoformat() if det.created_at else None, "updated_at": det.updated_at.isoformat() if det.updated_at else None, }) return {"items": items, "total": len(items)} # --------------------------------------------------------------------------- # Flood event map preview helpers # --------------------------------------------------------------------------- def _open_envi_rasterio(path: str): """打开 ENVI 格式栅格(路径可能无扩展名)。优先尝试裸路径,再尝试加 .bin/.img。""" import rasterio # 统一用正斜杠(Windows rasterio 也接受) path = path.replace("\\", "/") # 如果路径本身可以直接打开(ENVI 无扩展名文件) try: return rasterio.open(path) except Exception: pass # 尝试常见扩展名 for ext in (".bin", ".img", ".tif", ".tiff"): try: return rasterio.open(path + ext) except Exception: pass raise FileNotFoundError(f"无法打开栅格文件: {path}") def _raster_to_png_bytes(path: str, colormap: dict) -> tuple[bytes, list]: """将单波段分类栅格渲染为 RGBA PNG,返回 (png_bytes, [min_lat, min_lon, max_lat, max_lon])。""" import numpy as np from PIL import Image import io with _open_envi_rasterio(path) as ds: data = ds.read(1) bounds = ds.bounds geo_bounds = [bounds.bottom, bounds.left, bounds.top, bounds.right] h, w = data.shape rgba = np.zeros((h, w, 4), dtype=np.uint8) for val, color in colormap.items(): mask = data == val rgba[mask] = color img = Image.fromarray(rgba, "RGBA") buf = io.BytesIO() img.save(buf, format="PNG") return buf.getvalue(), geo_bounds def _geo_raster_to_png_bytes(path: str) -> tuple[bytes, list]: """将地理编码后的 SAR 强度图渲染为灰度 PNG(百分位拉伸)。""" import numpy as np from PIL import Image import io with _open_envi_rasterio(path) as ds: data = ds.read(1).astype(np.float32) nodata = ds.nodata bounds = ds.bounds geo_bounds = [bounds.bottom, bounds.left, bounds.top, bounds.right] # 构建 nodata 掩膜:优先用 ds.nodata,否则用 nan/inf 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: data_norm = np.zeros_like(data, dtype=np.uint8) else: p2, p98 = np.percentile(valid, 2), np.percentile(valid, 98) clipped = np.clip(data, p2, p98) data_norm = ((clipped - p2) / max(p98 - p2, 1e-9) * 255).astype(np.uint8) # 组装 RGBA:nodata 区域 alpha=0,其余 alpha=200 rgba = np.stack([data_norm, data_norm, data_norm, np.full_like(data_norm, 200)], axis=-1) rgba[nodata_mask, 3] = 0 img = Image.fromarray(rgba, "RGBA") buf = io.BytesIO() img.save(buf, format="PNG") return buf.getvalue(), geo_bounds # 分类结果色表:值 → RGBA _FLOOD_COLORMAP = { 1: (24, 144, 255, 200), # 稳定水体 — 蓝 2: (255, 77, 79, 220), # 洪涝 — 红 3: (250, 173, 20, 180), # 高散射 — 黄 4: (80, 80, 80, 80), # 非水体 — 灰(半透明) } @router.get("/water/flood-events/{event_id}/preview/classified") async def flood_event_classified_preview( event_id: int, db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_get_current_user), ): """返回洪涝分类结果的 PNG 预览图 + 地理范围(JSON)。""" from fastapi.responses import JSONResponse import base64 det = await db.get(FloodDetectionORM, event_id) if not det or not det.classified_path: raise HTTPException(status_code=404, detail="分类结果文件不存在") path = det.classified_path.replace("\\", "/") if not os.path.isfile(path): raise HTTPException(status_code=404, detail="请求的文件不存在") try: import asyncio png_bytes, geo_bounds = await asyncio.to_thread( _raster_to_png_bytes, path, _FLOOD_COLORMAP ) except Exception as e: raise HTTPException(status_code=500, detail=f"渲染失败: {e}") return JSONResponse({ "image_b64": base64.b64encode(png_bytes).decode(), "bounds": geo_bounds, # [min_lat, min_lon, max_lat, max_lon] "legend": { "稳定水体": "#1890ff", "洪涝": "#ff4d4f", "高散射": "#faad14", "非水体": "#505050", }, }) @router.get("/water/flood-events/{event_id}/preview/pre") async def flood_event_pre_preview( event_id: int, db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_get_current_user), ): """返回灾前地理编码影像的灰度 PNG 预览 + 地理范围。""" from fastapi.responses import JSONResponse import base64 det = await db.get(FloodDetectionORM, event_id) if not det: raise HTTPException(status_code=404, detail="记录不存在") pre = await db.get(SARSceneGeoORM, det.pre_scene_id) if not pre or not pre.geo_path: raise HTTPException(status_code=404, detail="灾前场景路径不存在") path = pre.geo_path.replace("\\", "/") if not os.path.isfile(path): raise HTTPException(status_code=404, detail="请求的文件不存在") try: import asyncio png_bytes, geo_bounds = await asyncio.to_thread(_geo_raster_to_png_bytes, path) except Exception as e: raise HTTPException(status_code=500, detail=f"渲染失败: {e}") return JSONResponse({ "image_b64": base64.b64encode(png_bytes).decode(), "bounds": geo_bounds, }) @router.get("/water/flood-events/{event_id}/preview/post") async def flood_event_post_preview( event_id: int, db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_get_current_user), ): """返回灾后地理编码影像的灰度 PNG 预览 + 地理范围。""" from fastapi.responses import JSONResponse import base64 det = await db.get(FloodDetectionORM, event_id) if not det: raise HTTPException(status_code=404, detail="记录不存在") post = await db.get(SARSceneGeoORM, det.post_scene_id) if not post or not post.geo_path: raise HTTPException(status_code=404, detail="灾后场景路径不存在") path = post.geo_path.replace("\\", "/") if not os.path.isfile(path): raise HTTPException(status_code=404, detail="请求的文件不存在") try: import asyncio png_bytes, geo_bounds = await asyncio.to_thread(_geo_raster_to_png_bytes, path) except Exception as e: raise HTTPException(status_code=500, detail=f"渲染失败: {e}") return JSONResponse({ "image_b64": base64.b64encode(png_bytes).decode(), "bounds": geo_bounds, }) @router.post("/water/find-pairs") async def find_water_pairs( req: WaterPairRequest, db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_get_current_user), ): """按时间范围查找满足重叠条件的灾前/灾后场景配对。""" # 查询灾前候选(status=DONE,日期在范围内) pre_filters = [SARSceneGeoORM.status == "DONE"] 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) ) pre_scenes = pre_result.all() # 查询灾后候选 post_filters = [SARSceneGeoORM.status == "DONE"] 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) ) post_scenes = post_result.all() # 计算所有候选配对的重叠率 candidates = [] for pre_scene, pre_radar in pre_scenes: for post_scene, post_radar in post_scenes: # 同一景不能自配 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": pre_scene.geo_path, }, "post": { "id": post_scene.id, "imaging_date": post_radar.imaging_date, "satellite": post_radar.satellite, "geo_path": post_scene.geo_path, }, "overlap_ratio": round(ratio, 4), "time_diff_days": time_diff, }) # 按重叠率降序,贪心去重:每个 pre/post 场景只出现一次 candidates.sort(key=lambda x: x["overlap_ratio"], reverse=True) used_pre, used_post = set(), set() pairs = [] for c in candidates: pid, qid = c["pre"]["id"], c["post"]["id"] if pid in used_pre or qid in used_post: continue used_pre.add(pid) used_post.add(qid) pairs.append(c) pairs.sort(key=lambda x: x["overlap_ratio"], reverse=True) return {"pairs": pairs, "total": len(pairs)} _UID_RE = _re.compile(r"_(\d{7,})$") @router.post("/water/sync-from-disk") async def sync_water_scenes_from_disk( db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_require_admin), ): """扫描 WATER_RESULTS_DIR,把有 geo_db 但未入库的场景补录为 DONE。""" water_dir = settings.WATER_RESULTS_DIR if not os.path.isdir(water_dir): raise HTTPException(status_code=400, detail=f"WATER_RESULTS_DIR 不存在: {water_dir}") # product_unique_id -> radar_data_id uid_rows = await db.execute( select(RadarDataORM.product_unique_id, RadarDataORM.id) .where(RadarDataORM.product_unique_id.isnot(None)) ) uid_to_radar_id: Dict[str, int] = {uid: rid for uid, rid in uid_rows.all() if uid} # 已有 DONE 记录的 radar_data_id done_rows = await db.execute( select(SARSceneGeoORM.radar_data_id).where(SARSceneGeoORM.status == "DONE") ) done_set = {rid for (rid,) in done_rows.all()} inserted = 0 skipped_no_geo_db = 0 skipped_no_match = 0 skipped_already_done = 0 for entry in os.scandir(water_dir): if not entry.is_dir() or not entry.name.startswith("scene_"): continue # 找 *_geo_db 文件 geo_db_path = None for f in os.scandir(entry.path): if f.name.endswith("_geo_db") and not f.name.endswith(".hdr") and not f.name.endswith(".sml"): geo_db_path = f.path break if not geo_db_path: skipped_no_geo_db += 1 continue m = _UID_RE.search(entry.name) if not m: skipped_no_match += 1 continue raw_uid = m.group(1) stripped_uid = raw_uid.lstrip("0") or raw_uid radar_id = uid_to_radar_id.get(raw_uid) or uid_to_radar_id.get(stripped_uid) if not radar_id: skipped_no_match += 1 continue if radar_id in done_set: skipped_already_done += 1 continue # 检查是否已有非 DONE 记录(upsert) existing = await db.execute( select(SARSceneGeoORM).where(SARSceneGeoORM.radar_data_id == radar_id) ) scene = existing.scalar_one_or_none() if scene: scene.status = "DONE" scene.geo_path = geo_db_path else: scene = SARSceneGeoORM( radar_data_id=radar_id, status="DONE", geo_path=geo_db_path, ) db.add(scene) inserted += 1 await db.commit() return { "inserted": inserted, "skipped_no_geo_db": skipped_no_geo_db, "skipped_no_match": skipped_no_match, "skipped_already_done": skipped_already_done, } # --------------------------------------------------------------------------- # Water body detection (Otsu + DEM + morphology) # --------------------------------------------------------------------------- class _WaterDetectReq(BaseModel): scene_id: Optional[int] = Field(default=None, description="SARSceneGeoORM 主键(可选)") input_path: Optional[str] = Field(default=None, description="直接指定 GeoTIFF 路径(可选)") @router.post("/water/detect", status_code=202) async def submit_water_detect( req: _WaterDetectReq, db: AsyncSession = Depends(get_db), admin_user: AuthUserORM = Depends(_require_admin), ): """提交水体检测任务(Otsu 自适应阈值 + DEM/坡度约束 + 形态学 + 连通分量过滤)。""" input_path = req.input_path scene_id = req.scene_id if scene_id: scene = await db.get(SARSceneGeoORM, scene_id) if not scene: raise HTTPException(status_code=404, detail=f"SARSceneGeoORM id={scene_id} 不存在") if not scene.geo_path: raise HTTPException(status_code=400, detail="该场景尚未完成地理编码") # geo_path is ENVI format; try to find a GeoTIFF equivalent or use the path input_path = scene.geo_path if not input_path: raise HTTPException(status_code=400, detail="必须提供 scene_id 或 input_path") det = WaterDetectionORM( scene_id=scene_id, input_path=input_path, status="PENDING", ) db.add(det) await db.flush() detection_id = det.id await db.commit() try: return await _queue_water_job( job_type=JOB_TYPE_WATER_DETECT, task_type=f"WATER_DETECT_{detection_id}", task_name=f"水体检测 id={detection_id}", payload={"detection_id": detection_id}, ) except HTTPException: async with db.begin(): d = await db.get(WaterDetectionORM, detection_id) if d and d.status == "PENDING": d.status = "FAILED" d.error_msg = "任务入队失败" raise @router.get("/water/detections") async def list_water_detections( limit: int = 20, offset: int = 0, status: Optional[str] = None, db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_get_current_user), ): """列出水体检测结果(分页)。""" from sqlalchemy import func q = select(func.count()).select_from(WaterDetectionORM) if status: q = q.where(WaterDetectionORM.status == status) total = (await db.execute(q)).scalar_one() q2 = select(WaterDetectionORM).order_by(WaterDetectionORM.id.desc()).limit(limit).offset(offset) if status: q2 = q2.where(WaterDetectionORM.status == status) rows = (await db.execute(q2)).scalars().all() items = [] for det in rows: items.append({ "id": det.id, "scene_id": det.scene_id, "input_path": det.input_path, "output_path": det.output_path, "water_area_km2": det.water_area_km2, "water_pixel_count": det.water_pixel_count, "otsu_threshold_db": det.otsu_threshold_db, "status": det.status, "error_msg": det.error_msg, "created_at": det.created_at.isoformat() if det.created_at else None, "updated_at": det.updated_at.isoformat() if det.updated_at else None, }) return {"items": items, "total": total} @router.get("/water/detections/{detection_id}") async def get_water_detection( detection_id: int, db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_get_current_user), ): """获取单个水体检测详情。""" det = await db.get(WaterDetectionORM, detection_id) if not det: raise HTTPException(status_code=404, detail=f"检测记录 id={detection_id} 不存在") return { "id": det.id, "scene_id": det.scene_id, "input_path": det.input_path, "output_path": det.output_path, "water_area_km2": det.water_area_km2, "water_pixel_count": det.water_pixel_count, "otsu_threshold_db": det.otsu_threshold_db, "status": det.status, "error_msg": det.error_msg, "created_at": det.created_at.isoformat() if det.created_at else None, "updated_at": det.updated_at.isoformat() if det.updated_at else None, } # --------------------------------------------------------------------------- # Water detection result preview (binary mask -> PNG) # --------------------------------------------------------------------------- @router.get("/water/detections/{detection_id}/preview") async def get_water_detection_preview( detection_id: int, db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_get_current_user), ): """返回水体检测结果的 PNG 预览及地理范围。""" det = await db.get(WaterDetectionORM, detection_id) if not det: raise HTTPException(status_code=404, detail=f"检测记录 id={detection_id} 不存在") if not det.output_path or not os.path.isfile(det.output_path): raise HTTPException(status_code=404, detail="输出文件不存在") import rasterio import io from PIL import Image import numpy as np from fastapi.responses import JSONResponse with rasterio.open(det.output_path) as src: data = src.read(1) transform = src.transform h, w = data.shape min_lon = transform.c max_lon = transform.c + w * transform.a max_lat = transform.f min_lat = transform.f + h * transform.e # Create RGBA image: water=blue semi-transparent, non-water=transparent rgba = np.zeros((data.shape[0], data.shape[1], 4), dtype=np.uint8) water_mask = data > 0 rgba[water_mask] = [24, 144, 255, 160] # #1890ff with alpha img = Image.fromarray(rgba, "RGBA") # Downsample if large max_dim = 1024 if max(w, h) > max_dim: ratio = max_dim / max(w, h) img = img.resize((int(w * ratio), int(h * ratio)), Image.NEAREST) buf = io.BytesIO() img.save(buf, format="PNG") import base64 png_b64 = base64.b64encode(buf.getvalue()).decode() return { "png_base64": png_b64, "bounds": { "min_lon": min_lon, "min_lat": min_lat, "max_lon": max_lon, "max_lat": max_lat, }, } # --------------------------------------------------------------------------- # GF3 L1A -> L2 processing # --------------------------------------------------------------------------- class _GF3ProcessReq(BaseModel): input_dir: str = Field(..., description="GF3 L1A 数据目录路径") resolution: float = Field(default=0.0002, ge=0.00001, le=0.01, description="输出分辨率(度)") @router.post("/water/gf3-process", status_code=202) async def submit_gf3_process( req: _GF3ProcessReq, db: AsyncSession = Depends(get_db), admin_user: AuthUserORM = Depends(_require_admin), ): """提交 GF3 L1A→L2 处理任务(辐射定标 + RPC 几何校正)。""" if not settings.GF3_LEGACY_GDAL_ENABLED: raise HTTPException( status_code=409, detail=( "Legacy GF3 Python/GDAL preprocessing is disabled. " "Use /monitor/gf3-sarscape-produce or /monitor/gf3-sarscape-sync." ), ) if not os.path.exists(req.input_dir): raise HTTPException(status_code=400, detail=f"输入路径不存在: {req.input_dir}") output_dir = os.path.join(settings.GF3_STORAGE_DIRS, f"gf3_{os.path.basename(req.input_dir)}") proc = GF3ProcessingORM( input_dir=req.input_dir, output_dir=output_dir, resolution=req.resolution, status="PENDING", ) db.add(proc) await db.flush() processing_id = proc.id await db.commit() try: return await _queue_water_job( job_type=JOB_TYPE_GF3_PROCESS, task_type=f"GF3_PROCESS_{processing_id}", task_name=f"GF3 处理 id={processing_id}", payload={"processing_id": processing_id}, ) except HTTPException: async with db.begin(): p = await db.get(GF3ProcessingORM, processing_id) if p and p.status == "PENDING": p.status = "FAILED" p.error_msg = "任务入队失败" raise @router.get("/water/gf3-results") async def list_gf3_results( limit: int = 20, offset: int = 0, db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_get_current_user), ): """列出 GF3 处理结果(分页)。""" from sqlalchemy import func total = (await db.execute( select(func.count()).select_from(GF3ProcessingORM) )).scalar_one() rows = (await db.execute( select(GF3ProcessingORM).order_by(GF3ProcessingORM.id.desc()).limit(limit).offset(offset) )).scalars().all() items = [] for proc in rows: items.append({ "id": proc.id, "input_dir": proc.input_dir, "output_dir": proc.output_dir, "polarizations": proc.polarizations, "l2_paths": proc.l2_paths, "resolution": proc.resolution, "status": proc.status, "error_msg": proc.error_msg, "created_at": proc.created_at.isoformat() if proc.created_at else None, "updated_at": proc.updated_at.isoformat() if proc.updated_at else None, }) return {"items": items, "total": total} @router.get("/water/gf3-results/{result_id}") async def get_gf3_result( result_id: int, db: AsyncSession = Depends(get_db), current_user: AuthUserORM = Depends(_get_current_user), ): """获取单个 GF3 处理结果详情。""" proc = await db.get(GF3ProcessingORM, result_id) if not proc: raise HTTPException(status_code=404, detail=f"GF3 处理记录 id={result_id} 不存在") return { "id": proc.id, "input_dir": proc.input_dir, "output_dir": proc.output_dir, "polarizations": proc.polarizations, "l2_paths": proc.l2_paths, "resolution": proc.resolution, "status": proc.status, "error_msg": proc.error_msg, "created_at": proc.created_at.isoformat() if proc.created_at else None, "updated_at": proc.updated_at.isoformat() if proc.updated_at else None, }