Add Gamma SBAS production workflow

This commit is contained in:
2026-05-21 04:53:39 +08:00
parent 9000feeee8
commit cc22b9ac2d
46 changed files with 11386 additions and 332 deletions
+12 -4
View File
@@ -63,6 +63,9 @@ MONITOR_DINSAR_DIRS=D:\DInSARResult
ORBIT_SOURCE_DIRS=D:\LT1_data_lsarorbit;D:\Sentinel1_EOF_Pool
MONITOR_ORBIT_DIR=D:\LT1_data_lsarorbit
GF3_ARCHIVE_SOURCE_DIRS=D:\GF3_L1A_Image_Zip
GF3_ARCHIVE_EXTS=.zip,.tar,.tar.gz,.tgz
GF3_UNPACK_DELETE_ARCHIVE=true
GF3_SOURCE_DIRS=D:\GF3_L1A_Image
GF3_STORAGE_DIRS=D:\GF3_L2_Image
@@ -101,6 +104,11 @@ IDL_DINSAR_DEM_BASE_FILE=D:\SRTM30m\SRTMDEM_RSP_SARscape
SRTM_DEM_DIR=D:\SRTM30m
GF3_GEO_DEM_PATH=D:\DEM\gf3_dem.jp2
WATER_RESULTS_DIR=D:\WaterResult
SAR_ANALYSIS_READY_ROOT=D:\production_results\sar_analysis_ready
SAR_ANALYSIS_WORK_ROOT=D:\production_results\sar_analysis_work
SAR_ANALYSIS_PREVIEW_ROOT=D:\production_results\sar_analysis_preview
SAR_ANALYSIS_NODATA_VALUE=-9999
SAR_ANALYSIS_OUTPUT_COG=true
# -----------------------------------------------------------------------------
@@ -215,16 +223,16 @@ PYINT_SMOKE_TEST_ENABLED=false
# -----------------------------------------------------------------------------
# 时序 InSAR
# 时序 InSAR(旧 ISCE2/MintPy 链路)
# -----------------------------------------------------------------------------
TIMESERIES_ENABLED=true
TIMESERIES_ENABLED=false
TIMESERIES_WSL_DISTRO=Ubuntu-24.04
TIMESERIES_ENV_NAME=insar_wsl_v1
TIMESERIES_PYTHON=/home/administrator/miniconda3/envs/insar_wsl_v1/bin/python
TIMESERIES_WORK_ROOT=D:\Code\Insar_management_system_v2\backend\runtime\timeseries_work
TIMESERIES_DEM_PATH=D:\SRTM30m\SRTMDEM_RSP_SARscape.wgs84
TIMESERIES_ORBIT_POOL_ISCE2=D:\orbit_pools\isce2
TIMESERIES_EXPERIMENT_ROOT=D:\Code\Insar_management_system_v2\experiments\isce2_sbas_timeseries
TIMESERIES_EXPERIMENT_ROOT=
TIMESERIES_STACK_PREP_SCRIPT=
TIMESERIES_MATERIALIZE_SCRIPT=
TIMESERIES_PREPARE_DEM_SCRIPT=
@@ -232,7 +240,7 @@ TIMESERIES_STACK_RUNNER_SCRIPT=
TIMESERIES_MINTPY_SBAS_SCRIPT=
TIMESERIES_EXPORT_PUBLISH_SCRIPT=
TIMESERIES_STACK_WORKFLOW=interferogram
TIMESERIES_DEFAULT_PROCESSOR_CODE=isce2_stack_mintpy
TIMESERIES_DEFAULT_PROCESSOR_CODE=legacy_isce2_stack_mintpy
TIMESERIES_WSL_STEP_TIMEOUT_SECONDS=7200
TIMESERIES_ALLOW_SYNTHETIC_WATER_MASK=true
SARSCAPE_SBAS_PARAMETER_TEMPLATE_PATH=
@@ -0,0 +1,152 @@
"""add flood pipeline models
Revision ID: 0003
Revises: 0002
Create Date: 2026-05-14
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from geoalchemy2 import Geometry
revision: str = "0003"
down_revision: Union[str, None] = "0002"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_table(table_name: str) -> bool:
return table_name in set(sa.inspect(op.get_bind()).get_table_names())
def _has_index(table_name: str, index_name: str) -> bool:
inspector = sa.inspect(op.get_bind())
return index_name in {idx["name"] for idx in inspector.get_indexes(table_name)}
def _create_index_if_missing(
table_name: str,
index_name: str,
columns: list[str],
*,
unique: bool = False,
**kwargs,
) -> None:
if _has_table(table_name) and not _has_index(table_name, index_name):
op.create_index(index_name, table_name, columns, unique=unique, **kwargs)
def upgrade() -> None:
op.execute("CREATE EXTENSION IF NOT EXISTS postgis")
if not _has_table("water_extractions"):
op.create_table(
"water_extractions",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("scene_id", sa.Integer(), nullable=True),
sa.Column("processor", sa.String(length=32), nullable=False, server_default="otsu"),
sa.Column("task_id", sa.String(length=64), nullable=True),
sa.Column("input_path", sa.String(), nullable=True),
sa.Column("output_path", sa.String(), nullable=True),
sa.Column("preview_path", sa.String(), nullable=True),
sa.Column("vector_path", sa.String(), nullable=True),
sa.Column("water_area_km2", sa.Float(), nullable=True),
sa.Column("water_pixel_count", sa.Integer(), nullable=True),
sa.Column("threshold_value", sa.Float(), nullable=True),
sa.Column("metadata_json", sa.JSON(), nullable=True),
sa.Column("status", sa.String(length=16), nullable=False, server_default="PENDING"),
sa.Column("error_msg", sa.Text(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=True),
sa.Column("updated_at", sa.DateTime(), server_default=sa.text("now()"), nullable=True),
sa.ForeignKeyConstraint(["scene_id"], ["sar_scene_geo.id"]),
sa.PrimaryKeyConstraint("id"),
)
_create_index_if_missing("water_extractions", "ix_water_extractions_scene_id", ["scene_id"])
_create_index_if_missing("water_extractions", "ix_water_extractions_processor", ["processor"])
_create_index_if_missing("water_extractions", "ix_water_extractions_task_id", ["task_id"])
_create_index_if_missing("water_extractions", "ix_water_extractions_status", ["status"])
if not _has_table("flood_overlays"):
op.create_table(
"flood_overlays",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("detection_id", sa.Integer(), nullable=False),
sa.Column("flood_vector_path", sa.String(), nullable=True),
sa.Column("hazard_points_hit", sa.Integer(), nullable=False, server_default="0"),
sa.Column("hazard_points_near", sa.Integer(), nullable=False, server_default="0"),
sa.Column("hazard_points_total", sa.Integer(), nullable=False, server_default="0"),
sa.Column("dinsar_products_intersecting", sa.Integer(), nullable=False, server_default="0"),
sa.Column("affected_area_km2", sa.Float(), nullable=True),
sa.Column("summary_json", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=True),
sa.ForeignKeyConstraint(["detection_id"], ["flood_detections.id"]),
sa.PrimaryKeyConstraint("id"),
)
_create_index_if_missing("flood_overlays", "ix_flood_overlays_detection_id", ["detection_id"])
if not _has_table("flood_products"):
op.create_table(
"flood_products",
sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
sa.Column("product_id", sa.String(length=64), nullable=False),
sa.Column("detection_id", sa.Integer(), nullable=True),
sa.Column("overlay_id", sa.Integer(), nullable=True),
sa.Column("display_name", sa.String(length=255), nullable=False),
sa.Column("status", sa.String(length=16), nullable=False, server_default="READY"),
sa.Column("publish_dir", sa.String(), nullable=True),
sa.Column("manifest_path", sa.String(), nullable=True),
sa.Column("geom", Geometry("POLYGON", srid=4326), nullable=True),
sa.Column("summary_json", sa.JSON(), nullable=True),
sa.Column("created_at", sa.DateTime(), server_default=sa.text("now()"), nullable=True),
sa.ForeignKeyConstraint(["detection_id"], ["flood_detections.id"]),
sa.ForeignKeyConstraint(["overlay_id"], ["flood_overlays.id"]),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("publish_dir", name="uq_flood_products_publish_dir"),
sa.UniqueConstraint("manifest_path", name="uq_flood_products_manifest_path"),
)
_create_index_if_missing("flood_products", "ix_flood_products_product_id", ["product_id"], unique=True)
_create_index_if_missing("flood_products", "ix_flood_products_detection_id", ["detection_id"])
_create_index_if_missing("flood_products", "ix_flood_products_overlay_id", ["overlay_id"])
_create_index_if_missing("flood_products", "ix_flood_products_status", ["status"])
if _has_table("water_detections") and _has_table("water_extractions"):
op.execute(
sa.text(
"""
INSERT INTO water_extractions (
id, scene_id, processor, input_path, output_path,
water_area_km2, water_pixel_count, threshold_value,
status, error_msg, created_at, updated_at
)
SELECT
wd.id, wd.scene_id, 'otsu', wd.input_path, wd.output_path,
wd.water_area_km2, wd.water_pixel_count, wd.otsu_threshold_db,
wd.status, wd.error_msg, wd.created_at, wd.updated_at
FROM water_detections wd
WHERE NOT EXISTS (
SELECT 1 FROM water_extractions we WHERE we.id = wd.id
)
"""
)
)
op.execute(
sa.text(
"""
SELECT setval(
pg_get_serial_sequence('water_extractions', 'id'),
COALESCE((SELECT MAX(id) FROM water_extractions), 1),
(SELECT COUNT(*) FROM water_extractions) > 0
)
"""
)
)
def downgrade() -> None:
if _has_table("flood_products"):
op.drop_table("flood_products")
if _has_table("flood_overlays"):
op.drop_table("flood_overlays")
if _has_table("water_extractions"):
op.drop_table("water_extractions")
@@ -0,0 +1,86 @@
"""add SAR analysis-ready scene fields
Revision ID: 0004
Revises: 0003
Create Date: 2026-05-16
"""
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
revision: str = "0004"
down_revision: Union[str, None] = "0003"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def _has_table(table_name: str) -> bool:
return table_name in set(sa.inspect(op.get_bind()).get_table_names())
def _has_column(table_name: str, column_name: str) -> bool:
inspector = sa.inspect(op.get_bind())
return column_name in {col["name"] for col in inspector.get_columns(table_name)}
def _has_index(table_name: str, index_name: str) -> bool:
inspector = sa.inspect(op.get_bind())
return index_name in {idx["name"] for idx in inspector.get_indexes(table_name)}
def _add_column_if_missing(table_name: str, column: sa.Column) -> None:
if _has_table(table_name) and not _has_column(table_name, column.name):
op.add_column(table_name, column)
def _create_index_if_missing(table_name: str, index_name: str, columns: list[str]) -> None:
if _has_table(table_name) and not _has_index(table_name, index_name):
op.create_index(index_name, table_name, columns)
def _drop_index_if_exists(table_name: str, index_name: str) -> None:
if _has_table(table_name) and _has_index(table_name, index_name):
op.drop_index(index_name, table_name=table_name)
def _drop_column_if_exists(table_name: str, column_name: str) -> None:
if _has_table(table_name) and _has_column(table_name, column_name):
op.drop_column(table_name, column_name)
def upgrade() -> None:
table = "sar_scene_geo"
_add_column_if_missing(table, sa.Column("analysis_tif_path", sa.String(), nullable=True))
_add_column_if_missing(table, sa.Column("analysis_dir", sa.String(), nullable=True))
_add_column_if_missing(table, sa.Column("analysis_preview_path", sa.String(), nullable=True))
_add_column_if_missing(table, sa.Column("analysis_engine", sa.String(length=32), nullable=True))
_add_column_if_missing(table, sa.Column("analysis_profile", sa.String(length=64), nullable=True))
_add_column_if_missing(table, sa.Column("analysis_backscatter_unit", sa.String(length=32), nullable=True))
_add_column_if_missing(table, sa.Column("analysis_nodata_value", sa.Float(), nullable=True))
_add_column_if_missing(table, sa.Column("analysis_metadata_json", sa.JSON(), nullable=True))
_add_column_if_missing(table, sa.Column("analysis_quality_json", sa.JSON(), nullable=True))
_create_index_if_missing(table, "ix_sar_scene_geo_analysis_tif_path", ["analysis_tif_path"])
_create_index_if_missing(table, "ix_sar_scene_geo_analysis_engine", ["analysis_engine"])
_create_index_if_missing(table, "ix_sar_scene_geo_analysis_profile", ["analysis_profile"])
def downgrade() -> None:
table = "sar_scene_geo"
_drop_index_if_exists(table, "ix_sar_scene_geo_analysis_profile")
_drop_index_if_exists(table, "ix_sar_scene_geo_analysis_engine")
_drop_index_if_exists(table, "ix_sar_scene_geo_analysis_tif_path")
for column_name in (
"analysis_quality_json",
"analysis_metadata_json",
"analysis_nodata_value",
"analysis_backscatter_unit",
"analysis_profile",
"analysis_engine",
"analysis_preview_path",
"analysis_dir",
"analysis_tif_path",
):
_drop_column_if_exists(table, column_name)
+142 -97
View File
@@ -195,9 +195,17 @@ class Settings(BaseSettings):
RADAR_PREVIEW_BUILD_ON_DEMAND: bool = True
WATER_RESULTS_DIR: str = ""
SAR_ANALYSIS_READY_ROOT: str = ""
SAR_ANALYSIS_WORK_ROOT: str = ""
SAR_ANALYSIS_PREVIEW_ROOT: str = ""
SAR_ANALYSIS_NODATA_VALUE: float = -9999.0
SAR_ANALYSIS_OUTPUT_COG: bool = True
SRTM_DEM_DIR: str = ""
GF3_GEO_DEM_PATH: str = ""
GF3_ARCHIVE_SOURCE_DIRS: str = ""
GF3_ARCHIVE_EXTS: str = ".zip,.tar,.tar.gz,.tgz"
GF3_UNPACK_DELETE_ARCHIVE: bool = True
GF3_SOURCE_DIRS: str = ""
GF3_STORAGE_DIRS: str = ""
@@ -306,7 +314,7 @@ class Settings(BaseSettings):
JOB_WORKER_STALE_RUNNING_SECONDS: int = 300
JOB_WORKER_HEARTBEAT_INTERVAL: float = 5.0
TIMESERIES_ENABLED: bool = True
TIMESERIES_ENABLED: bool = False
TIMESERIES_WSL_DISTRO: str = ""
TIMESERIES_PYTHON: str = ""
TIMESERIES_ENV_NAME: str = ""
@@ -354,6 +362,29 @@ class Settings(BaseSettings):
"WATER_RESULTS_DIR",
os.path.join(backend_dir, "water_results"),
)
if not self.SAR_ANALYSIS_READY_ROOT:
object.__setattr__(
self,
"SAR_ANALYSIS_READY_ROOT",
os.path.join(backend_dir, "runtime", "sar_analysis_ready"),
)
if not self.SAR_ANALYSIS_WORK_ROOT:
object.__setattr__(
self,
"SAR_ANALYSIS_WORK_ROOT",
os.path.join(backend_dir, "runtime", "sar_analysis_work"),
)
if not self.SAR_ANALYSIS_PREVIEW_ROOT:
object.__setattr__(
self,
"SAR_ANALYSIS_PREVIEW_ROOT",
os.path.join(backend_dir, "runtime", "sar_analysis_preview"),
)
object.__setattr__(
self,
"SAR_ANALYSIS_NODATA_VALUE",
float(self.SAR_ANALYSIS_NODATA_VALUE if self.SAR_ANALYSIS_NODATA_VALUE is not None else -9999.0),
)
if not self.SRTM_DEM_DIR:
object.__setattr__(self, "SRTM_DEM_DIR", os.path.join(backend_dir, "dem_data"))
if not self.GF3_STORAGE_DIRS:
@@ -592,104 +623,105 @@ class Settings(BaseSettings):
)
if not self.PYINT_ORBIT_POOL_TXT:
object.__setattr__(self, "PYINT_ORBIT_POOL_TXT", self.ORBIT_POOL_ENVI)
if not self.TIMESERIES_WSL_DISTRO:
object.__setattr__(self, "TIMESERIES_WSL_DISTRO", self.WSL_DISTRO or self.ISCE2_WSL_DISTRO)
if not self.TIMESERIES_ENV_NAME:
object.__setattr__(
self,
"TIMESERIES_ENV_NAME",
str(self.WSL_SHARED_CONDA_ENV or "insar_wsl_v1").strip() or "insar_wsl_v1",
)
if not self.TIMESERIES_PYTHON:
shared_python = str(self.WSL_SHARED_PYTHON or "").strip()
if shared_python:
object.__setattr__(self, "TIMESERIES_PYTHON", shared_python)
else:
env_name = (
str(self.TIMESERIES_ENV_NAME or "insar_wsl_v1").strip()
or "insar_wsl_v1"
)
if self.TIMESERIES_ENABLED:
if not self.TIMESERIES_WSL_DISTRO:
object.__setattr__(self, "TIMESERIES_WSL_DISTRO", self.WSL_DISTRO or self.ISCE2_WSL_DISTRO)
if not self.TIMESERIES_ENV_NAME:
object.__setattr__(
self,
"TIMESERIES_PYTHON",
f"/home/administrator/miniconda3/envs/{env_name}/bin/python",
"TIMESERIES_ENV_NAME",
str(self.WSL_SHARED_CONDA_ENV or "insar_wsl_v1").strip() or "insar_wsl_v1",
)
if not self.TIMESERIES_PYTHON:
shared_python = str(self.WSL_SHARED_PYTHON or "").strip()
if shared_python:
object.__setattr__(self, "TIMESERIES_PYTHON", shared_python)
else:
env_name = (
str(self.TIMESERIES_ENV_NAME or "insar_wsl_v1").strip()
or "insar_wsl_v1"
)
object.__setattr__(
self,
"TIMESERIES_PYTHON",
f"/home/administrator/miniconda3/envs/{env_name}/bin/python",
)
if not self.TIMESERIES_WORK_ROOT:
object.__setattr__(
self,
"TIMESERIES_WORK_ROOT",
os.path.join(backend_dir, "runtime", "timeseries_work"),
)
if not self.TIMESERIES_DEM_PATH:
object.__setattr__(self, "TIMESERIES_DEM_PATH", self.ISCE2_DEM_PATH)
if not self.TIMESERIES_ORBIT_POOL_ISCE2:
object.__setattr__(self, "TIMESERIES_ORBIT_POOL_ISCE2", self.ORBIT_POOL_ISCE2)
if not self.TIMESERIES_EXPERIMENT_ROOT:
object.__setattr__(
self,
"TIMESERIES_EXPERIMENT_ROOT",
os.path.join(project_root, "experiments", "isce2_sbas_timeseries"),
)
if not self.TIMESERIES_STACK_PREP_SCRIPT:
object.__setattr__(
self,
"TIMESERIES_STACK_PREP_SCRIPT",
os.path.join(
self.TIMESERIES_EXPERIMENT_ROOT,
"scripts",
"build_lt1_stack_prep.py",
),
)
if not self.TIMESERIES_MATERIALIZE_SCRIPT:
object.__setattr__(
self,
"TIMESERIES_MATERIALIZE_SCRIPT",
os.path.join(
self.TIMESERIES_EXPERIMENT_ROOT,
"scripts",
"materialize_lt1_stack_scenes.py",
),
)
if not self.TIMESERIES_PREPARE_DEM_SCRIPT:
object.__setattr__(
self,
"TIMESERIES_PREPARE_DEM_SCRIPT",
os.path.join(
self.TIMESERIES_EXPERIMENT_ROOT,
"scripts",
"prepare_lt1_stack_dem.py",
),
)
if not self.TIMESERIES_STACK_RUNNER_SCRIPT:
object.__setattr__(
self,
"TIMESERIES_STACK_RUNNER_SCRIPT",
os.path.join(
self.TIMESERIES_EXPERIMENT_ROOT,
"scripts",
"run_generated_stack_runfile_ubuntu2404.sh",
),
)
if not self.TIMESERIES_MINTPY_SBAS_SCRIPT:
object.__setattr__(
self,
"TIMESERIES_MINTPY_SBAS_SCRIPT",
os.path.join(
self.TIMESERIES_EXPERIMENT_ROOT,
"scripts",
"run_mintpy_sbas_unified_env_smoketest_ubuntu2404.sh",
),
)
if not self.TIMESERIES_EXPORT_PUBLISH_SCRIPT:
object.__setattr__(
self,
"TIMESERIES_EXPORT_PUBLISH_SCRIPT",
os.path.join(
self.TIMESERIES_EXPERIMENT_ROOT,
"scripts",
"export_mintpy_publish_products_unified_env_ubuntu2404.sh",
),
)
if not self.TIMESERIES_WORK_ROOT:
object.__setattr__(
self,
"TIMESERIES_WORK_ROOT",
os.path.join(backend_dir, "runtime", "timeseries_work"),
)
if not self.TIMESERIES_DEM_PATH:
object.__setattr__(self, "TIMESERIES_DEM_PATH", self.ISCE2_DEM_PATH)
if not self.TIMESERIES_ORBIT_POOL_ISCE2:
object.__setattr__(self, "TIMESERIES_ORBIT_POOL_ISCE2", self.ORBIT_POOL_ISCE2)
if not self.TIMESERIES_EXPERIMENT_ROOT:
object.__setattr__(
self,
"TIMESERIES_EXPERIMENT_ROOT",
os.path.join(project_root, "experiments", "isce2_sbas_timeseries"),
)
if not self.TIMESERIES_STACK_PREP_SCRIPT:
object.__setattr__(
self,
"TIMESERIES_STACK_PREP_SCRIPT",
os.path.join(
self.TIMESERIES_EXPERIMENT_ROOT,
"scripts",
"build_lt1_stack_prep.py",
),
)
if not self.TIMESERIES_MATERIALIZE_SCRIPT:
object.__setattr__(
self,
"TIMESERIES_MATERIALIZE_SCRIPT",
os.path.join(
self.TIMESERIES_EXPERIMENT_ROOT,
"scripts",
"materialize_lt1_stack_scenes.py",
),
)
if not self.TIMESERIES_PREPARE_DEM_SCRIPT:
object.__setattr__(
self,
"TIMESERIES_PREPARE_DEM_SCRIPT",
os.path.join(
self.TIMESERIES_EXPERIMENT_ROOT,
"scripts",
"prepare_lt1_stack_dem.py",
),
)
if not self.TIMESERIES_STACK_RUNNER_SCRIPT:
object.__setattr__(
self,
"TIMESERIES_STACK_RUNNER_SCRIPT",
os.path.join(
self.TIMESERIES_EXPERIMENT_ROOT,
"scripts",
"run_generated_stack_runfile_ubuntu2404.sh",
),
)
if not self.TIMESERIES_MINTPY_SBAS_SCRIPT:
object.__setattr__(
self,
"TIMESERIES_MINTPY_SBAS_SCRIPT",
os.path.join(
self.TIMESERIES_EXPERIMENT_ROOT,
"scripts",
"run_mintpy_sbas_unified_env_smoketest_ubuntu2404.sh",
),
)
if not self.TIMESERIES_EXPORT_PUBLISH_SCRIPT:
object.__setattr__(
self,
"TIMESERIES_EXPORT_PUBLISH_SCRIPT",
os.path.join(
self.TIMESERIES_EXPERIMENT_ROOT,
"scripts",
"export_mintpy_publish_products_unified_env_ubuntu2404.sh",
),
)
if not self.SARSCAPE_SBAS_PARAMETER_TEMPLATE_PATH:
object.__setattr__(
self,
@@ -715,12 +747,16 @@ class Settings(BaseSettings):
os.makedirs(settings.TIMESERIES_PRODUCT_DIR, exist_ok=True)
os.makedirs(settings.PSINSAR_PRODUCT_DIR, exist_ok=True)
os.makedirs(settings.RESULT_QUARANTINE_ROOT, exist_ok=True)
os.makedirs(settings.SAR_ANALYSIS_READY_ROOT, exist_ok=True)
os.makedirs(settings.SAR_ANALYSIS_WORK_ROOT, exist_ok=True)
os.makedirs(settings.SAR_ANALYSIS_PREVIEW_ROOT, exist_ok=True)
os.makedirs(settings.WSL_BROKER_JOB_ROOT, exist_ok=True)
os.makedirs(settings.PYINT_TEMPLATE_ROOT, exist_ok=True)
os.makedirs(settings.PYINT_WORK_ROOT, exist_ok=True)
os.makedirs(settings.PYINT_OUTPUT_ROOT, exist_ok=True)
os.makedirs(settings.PYINT_DEM_ROOT, exist_ok=True)
os.makedirs(settings.TIMESERIES_WORK_ROOT, exist_ok=True)
if settings.TIMESERIES_ENABLED and settings.TIMESERIES_WORK_ROOT:
os.makedirs(settings.TIMESERIES_WORK_ROOT, exist_ok=True)
settings = Settings()
@@ -861,6 +897,9 @@ def validate_runtime_config() -> dict[str, Any]:
_check_path(label="GF3_GEO_DEM_PATH", value=settings.GF3_GEO_DEM_PATH, errors=errors, warnings=warnings, expect_file=True)
_check_path(label="SRTM_DEM_DIR", value=settings.SRTM_DEM_DIR, errors=errors, warnings=warnings, expect_file=False)
_check_path(label="WATER_RESULTS_DIR", value=settings.WATER_RESULTS_DIR, errors=errors, warnings=warnings, expect_file=False)
_check_path(label="SAR_ANALYSIS_READY_ROOT", value=settings.SAR_ANALYSIS_READY_ROOT, errors=errors, warnings=warnings, expect_file=False)
_check_path(label="SAR_ANALYSIS_WORK_ROOT", value=settings.SAR_ANALYSIS_WORK_ROOT, errors=errors, warnings=warnings, expect_file=False)
_check_path(label="SAR_ANALYSIS_PREVIEW_ROOT", value=settings.SAR_ANALYSIS_PREVIEW_ROOT, errors=errors, warnings=warnings, expect_file=False)
_check_path(label="MONITOR_ORBIT_DIR", value=settings.MONITOR_ORBIT_DIR, errors=errors, warnings=warnings, expect_file=False)
for label, value in (
("SOURCE_PRODUCT_DIRS", settings.SOURCE_PRODUCT_DIRS),
@@ -887,6 +926,7 @@ def validate_runtime_config() -> dict[str, Any]:
("INSAR_STORAGE_DIRS", settings.INSAR_STORAGE_DIRS),
("MONITOR_RADAR_DIRS", settings.MONITOR_RADAR_DIRS),
("MONITOR_DINSAR_DIRS", settings.MONITOR_DINSAR_DIRS),
("GF3_ARCHIVE_SOURCE_DIRS", settings.GF3_ARCHIVE_SOURCE_DIRS),
("GF3_SOURCE_DIRS", settings.GF3_SOURCE_DIRS),
("GF3_STORAGE_DIRS", settings.GF3_STORAGE_DIRS),
):
@@ -1030,6 +1070,11 @@ def validate_runtime_config() -> dict[str, Any]:
"Gamma runtime has not been fully migrated to the fixed profile model."
)
if not settings.TIMESERIES_ENABLED:
info.append(
"Legacy ISCE2/MintPy timeseries pipeline is disabled; current SBAS-InSAR production uses the Gamma /sbas-insar-production workflow."
)
if settings.TIMESERIES_ENABLED:
_check_path(
label="TIMESERIES_PYTHON",
+60 -1
View File
@@ -6,7 +6,7 @@ from urllib.parse import urlparse
from sqlalchemy import create_engine, inspect, text
from sqlalchemy.orm import sessionmaker
from sqlalchemy.schema import CreateColumn
from sqlalchemy.schema import CreateColumn, CreateIndex
from .config import read_bool_env, settings
@@ -23,9 +23,12 @@ POSTGIS_VIEWS = {
}
ALLOWED_EXTRA_TABLES = {
"alembic_version",
"spatial_query_logs",
}
ALEMBIC_HEAD_REVISION = "0004"
MIGRATION_FILES = [
"001_st_intersection_agg.sql",
"002_spatial_functions.sql",
@@ -337,6 +340,50 @@ def _add_missing_columns(conn, inspector, base, dialect) -> List[str]:
return added_columns
def _create_missing_indexes(conn, inspector, base, dialect) -> List[str]:
created_indexes: List[str] = []
existing_tables = set(inspector.get_table_names())
for table_name, table in base.metadata.tables.items():
if table_name not in existing_tables:
continue
current_indexes = {index["name"] for index in inspector.get_indexes(table_name)}
for index in sorted(table.indexes, key=lambda item: item.name or ""):
if not index.name or index.name in current_indexes:
continue
try:
index_sql = str(CreateIndex(index).compile(dialect=dialect))
conn.exec_driver_sql(index_sql)
created_indexes.append(index.name)
current_indexes.add(index.name)
except Exception as exc:
print(f"[WARN] Failed to create index {table_name}.{index.name}: {exc}")
return created_indexes
def _ensure_alembic_version_marker(conn, revision: str = ALEMBIC_HEAD_REVISION) -> Dict[str, Any]:
status: Dict[str, Any] = {"revision": revision, "created": False, "updated": False}
conn.exec_driver_sql(
"""
CREATE TABLE IF NOT EXISTS alembic_version (
version_num VARCHAR(32) NOT NULL
)
"""
)
rows = conn.execute(text("SELECT version_num FROM alembic_version")).fetchall()
previous = [row[0] for row in rows]
status["previous"] = previous
if not rows:
conn.execute(text("INSERT INTO alembic_version (version_num) VALUES (:revision)"), {"revision": revision})
status["created"] = True
return status
if previous != [revision] or len(rows) != 1:
conn.execute(text("DELETE FROM alembic_version"))
conn.execute(text("INSERT INTO alembic_version (version_num) VALUES (:revision)"), {"revision": revision})
status["updated"] = True
return status
def _resolve_hazard_shapefile() -> str:
hazard_dir = settings.HAZARD_POINTS_DIR
hazard_filename = settings.HAZARD_POINTS_FILENAME or "Point.shp"
@@ -513,6 +560,8 @@ def ensure_database_ready(
"mismatch_detected": False,
"mismatch_reasons": [],
"added_columns": [],
"created_indexes": [],
"alembic_version": None,
"applied_sql_files": [],
"admin": None,
"hazard_seed": None,
@@ -545,10 +594,14 @@ def ensure_database_ready(
base.metadata.create_all(bind=conn)
inspector = inspect(conn)
result["added_columns"] = _add_missing_columns(conn, inspector, base, engine.dialect)
inspector = inspect(conn)
result["created_indexes"] = _create_missing_indexes(conn, inspector, base, engine.dialect)
if bootstrap_required:
result["bootstrap_initialized"] = True
else:
base.metadata.create_all(bind=conn)
inspector = inspect(conn)
result["created_indexes"] = _create_missing_indexes(conn, inspector, base, engine.dialect)
migrations_dir = os.path.join(project_root(), "backend", "migrations")
for migration_file in MIGRATION_FILES:
@@ -556,6 +609,12 @@ def ensure_database_ready(
if _apply_sql_file(conn, migration_path):
result["applied_sql_files"].append(migration_file)
final_diagnostics = inspect_database_structure(conn)
if not final_diagnostics.get("mismatch"):
result["alembic_version"] = _ensure_alembic_version_marker(conn)
else:
result["post_maintenance_mismatch_reasons"] = final_diagnostics.get("reasons", [])
session = Session()
try:
if bootstrap_admin:
+5
View File
@@ -108,6 +108,11 @@ async def lifespan(app: FastAPI):
)
if maintenance_result.get("added_columns"):
print(f">>> [DB] Added columns: {maintenance_result['added_columns']}")
if maintenance_result.get("created_indexes"):
print(f">>> [DB] Created indexes: {maintenance_result['created_indexes']}")
if maintenance_result.get("alembic_version"):
alembic_status = maintenance_result["alembic_version"]
print(f">>> [DB] Alembic version marker: {alembic_status.get('revision')}")
if maintenance_result.get("admin", {}).get("message"):
print(f">>> [DB] {maintenance_result['admin']['message']}")
if maintenance_result.get("hazard_seed", {}).get("message"):
+5 -1
View File
@@ -52,6 +52,9 @@ from .orm import (
SARSceneGeoORM,
FloodDetectionORM,
WaterDetectionORM,
WaterExtractionORM,
FloodOverlayORM,
FloodProductORM,
GF3ProcessingORM,
AiDiagnosisORM,
)
@@ -110,7 +113,8 @@ __all__ = [
"PsTimeseriesRunORM",
"AuthUserORM", "AuthSessionORM", "AuthAuditLogORM", "AuthRateLimitORM",
"SARSceneGeoORM", "FloodDetectionORM",
"WaterDetectionORM", "GF3ProcessingORM", "AiDiagnosisORM",
"WaterDetectionORM", "WaterExtractionORM", "FloodOverlayORM", "FloodProductORM",
"GF3ProcessingORM", "AiDiagnosisORM",
# Schemas
"HazardPoint", "DinsarResult", "ScanRequest", "ManagedRootInfo", "ScanCursorInfo",
"RadarData", "RadarDataPage", "DinsarResultPage",
+74
View File
@@ -1478,6 +1478,15 @@ class SARSceneGeoORM(Base):
radar_data_id = Column(Integer, ForeignKey("radar_data.id"), index=True, nullable=False)
geo_path = Column(String, nullable=True) # 地理编码 dB 文件路径(ENVI 格式,无扩展名)
pixel_size_m = Column(Float, nullable=True) # 输出像素大小(m)
analysis_tif_path = Column(String, nullable=True, index=True)
analysis_dir = Column(String, nullable=True)
analysis_preview_path = Column(String, nullable=True)
analysis_engine = Column(String(32), nullable=True, index=True)
analysis_profile = Column(String(64), nullable=True, index=True)
analysis_backscatter_unit = Column(String(32), nullable=True)
analysis_nodata_value = Column(Float, nullable=True)
analysis_metadata_json = Column(JSON, nullable=True)
analysis_quality_json = Column(JSON, nullable=True)
status = Column(String, nullable=False, default="PENDING", index=True)
error_msg = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
@@ -1514,6 +1523,8 @@ class FloodDetectionORM(Base):
pre_scene = relationship("SARSceneGeoORM", foreign_keys=[pre_scene_id], back_populates="pre_flood_detections")
post_scene = relationship("SARSceneGeoORM", foreign_keys=[post_scene_id], back_populates="post_flood_detections")
overlays = relationship("FloodOverlayORM", back_populates="detection")
products = relationship("FloodProductORM", back_populates="detection", foreign_keys="FloodProductORM.detection_id")
__table_args__ = (
UniqueConstraint("pre_scene_id", "post_scene_id", name="uq_flood_detection_pair"),
@@ -1537,6 +1548,69 @@ class WaterDetectionORM(Base):
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
class WaterExtractionORM(Base):
"""Water extraction result used by the flood-analysis pipeline."""
__tablename__ = "water_extractions"
id = Column(Integer, primary_key=True, autoincrement=True)
scene_id = Column(Integer, ForeignKey("sar_scene_geo.id"), nullable=True, index=True)
processor = Column(String(32), nullable=False, default="otsu", index=True)
task_id = Column(String(64), nullable=True, index=True)
input_path = Column(String, nullable=True)
output_path = Column(String, nullable=True)
preview_path = Column(String, nullable=True)
vector_path = Column(String, nullable=True)
water_area_km2 = Column(Float, nullable=True)
water_pixel_count = Column(Integer, nullable=True)
threshold_value = Column(Float, nullable=True)
metadata_json = Column(JSON, nullable=True)
status = Column(String(16), nullable=False, default="PENDING", index=True)
error_msg = Column(Text, nullable=True)
created_at = Column(DateTime, server_default=func.now())
updated_at = Column(DateTime, server_default=func.now(), onupdate=func.now())
scene = relationship("SARSceneGeoORM", foreign_keys=[scene_id])
class FloodOverlayORM(Base):
"""Spatial overlay result for a flood detection."""
__tablename__ = "flood_overlays"
id = Column(Integer, primary_key=True, autoincrement=True)
detection_id = Column(Integer, ForeignKey("flood_detections.id"), nullable=False, index=True)
flood_vector_path = Column(String, nullable=True)
hazard_points_hit = Column(Integer, nullable=False, default=0)
hazard_points_near = Column(Integer, nullable=False, default=0)
hazard_points_total = Column(Integer, nullable=False, default=0)
dinsar_products_intersecting = Column(Integer, nullable=False, default=0)
affected_area_km2 = Column(Float, nullable=True)
summary_json = Column(JSON, nullable=True)
created_at = Column(DateTime, server_default=func.now())
detection = relationship("FloodDetectionORM", back_populates="overlays")
products = relationship("FloodProductORM", back_populates="overlay", foreign_keys="FloodProductORM.overlay_id")
class FloodProductORM(Base):
"""Published flood-analysis product package."""
__tablename__ = "flood_products"
id = Column(Integer, primary_key=True, autoincrement=True)
product_id = Column(String(64), unique=True, index=True, nullable=False)
detection_id = Column(Integer, ForeignKey("flood_detections.id"), nullable=True, index=True)
overlay_id = Column(Integer, ForeignKey("flood_overlays.id"), nullable=True, index=True)
display_name = Column(String(255), nullable=False)
status = Column(String(16), nullable=False, default="READY", index=True)
publish_dir = Column(String, unique=True, nullable=True)
manifest_path = Column(String, unique=True, nullable=True)
geom = Column(Geometry("POLYGON", srid=4326), nullable=True)
summary_json = Column(JSON, nullable=True)
created_at = Column(DateTime, server_default=func.now())
detection = relationship("FloodDetectionORM", back_populates="products", foreign_keys=[detection_id])
overlay = relationship("FloodOverlayORM", back_populates="products", foreign_keys=[overlay_id])
class GF3ProcessingORM(Base):
"""GF3 L1A→L2 处理记录(辐射定标 + RPC 几何校正)。"""
__tablename__ = "gf3_processing"
@@ -0,0 +1,326 @@
#!/usr/bin/env python3
"""Single-scene Gamma preprocessing to analysis-ready GeoTIFF.
The script is intentionally narrower than the full PyINT DInSAR pipeline:
LT source product -> Gamma SLC -> multilook amplitude -> geocode -> GeoTIFF.
It is executed inside WSL by backend.app.services.lt_gamma_scene_service.
"""
from __future__ import annotations
import argparse
import json
import math
import os
import re
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Any
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(description="Preprocess a SAR scene with Gamma/PyINT.")
parser.add_argument("--source-path", required=True)
parser.add_argument("--output-dir", required=True)
parser.add_argument("--work-dir", required=True)
parser.add_argument("--pyint-home", required=True)
parser.add_argument("--dem-root", required=True)
parser.add_argument("--prepared-dem-path", default="")
parser.add_argument("--project-name", required=True)
parser.add_argument("--date", required=True)
parser.add_argument("--satellite-family", default="LT1")
parser.add_argument("--range-looks", type=int, default=2)
parser.add_argument("--azimuth-looks", type=int, default=2)
parser.add_argument("--geo-interp", default="1")
parser.add_argument("--nodata-value", type=float, default=-9999.0)
parser.add_argument("--to-db", action="store_true")
return parser.parse_args()
def run_logged(command: list[str], *, cwd: Path, env: dict[str, str], log_dir: Path, stage: str) -> dict[str, Any]:
log_dir.mkdir(parents=True, exist_ok=True)
stdout_path = log_dir / f"{stage}.stdout.log"
stderr_path = log_dir / f"{stage}.stderr.log"
result = subprocess.run(command, cwd=str(cwd), env=env, text=True, capture_output=True, check=False)
stdout_path.write_text(result.stdout or "", encoding="utf-8", errors="ignore")
stderr_path.write_text(result.stderr or "", encoding="utf-8", errors="ignore")
if result.returncode != 0:
detail = (result.stderr or result.stdout or "").strip()
raise RuntimeError(f"{stage} failed rc={result.returncode}: {' '.join(command)}\n{detail}")
return {
"stage": stage,
"command": command,
"returncode": result.returncode,
"stdout_path": str(stdout_path),
"stderr_path": str(stderr_path),
}
def read_gamma_par(path: Path, key: str) -> str:
wanted = str(key or "").strip().rstrip(":")
with path.open("r", encoding="utf-8", errors="ignore") as stream:
for line in stream:
stripped = line.strip()
if not stripped:
continue
label = stripped.split()[0].rstrip(":")
if label != wanted:
continue
tail = stripped.split(":", 1)[1] if ":" in stripped else " ".join(stripped.split()[1:])
tokens = tail.strip().split()
if tokens:
return tokens[0]
raise KeyError(f"Cannot read {key} from {path}")
def discover_lt_inputs(source_path: Path, date: str) -> list[Path]:
patterns = [f"LT1*{date}*.tar.gz", f"LT1*{date}*.tiff", f"LT1*{date}*.tif"]
if source_path.is_file():
return [source_path]
if not source_path.is_dir():
raise FileNotFoundError(f"Source path does not exist: {source_path}")
found: list[Path] = []
for pattern in patterns:
found.extend(path for path in source_path.rglob(pattern) if path.is_file())
return sorted(set(found))
def stage_lt_inputs(source_path: Path, download_dir: Path, date: str) -> list[str]:
download_dir.mkdir(parents=True, exist_ok=True)
inputs = discover_lt_inputs(source_path, date)
if not inputs:
raise FileNotFoundError(f"No LT inputs for date {date} under {source_path}")
staged: list[str] = []
for source in inputs:
target = download_dir / source.name
shutil.copy2(source, target)
staged.append(str(target))
lower_name = source.name.lower()
if lower_name.endswith((".tiff", ".tif")):
base_candidates = [
source.with_suffix(source.suffix + ".meta.xml"),
source.with_suffix(".meta.xml"),
source.with_name(source.stem + ".meta.xml"),
]
for meta in base_candidates:
if meta.is_file():
shutil.copy2(meta, download_dir / meta.name)
break
return staged
def write_template(
*,
template_path: Path,
date: str,
range_looks: int,
azimuth_looks: int,
geo_interp: str,
prepared_dem_path: str,
) -> None:
lines = [
"satelite = LT",
f"masterDate = {date}",
f"range_looks = {range_looks}",
f"azimuth_looks = {azimuth_looks}",
"dem_lat_ovr = 0.5",
"dem_lon_ovr = 0.5",
"Simphase_rpos = -",
"Simphase_azpos = -",
"Simphase_rwin = 256",
"Simphase_azwin = 256",
"Simphase_thresh = -",
f"geo_interp = {geo_interp}",
]
dem = str(prepared_dem_path or "").strip()
if dem and Path(dem).is_file() and Path(dem + ".par").is_file():
lines.append(f"DEM = {dem}")
template_path.parent.mkdir(parents=True, exist_ok=True)
template_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
def convert_to_db_geotiff(source_tif: Path, target_tif: Path, nodata_value: float) -> dict[str, Any]:
try:
import numpy as np
import rasterio
except Exception as exc:
shutil.copy2(source_tif, target_tif)
return {
"target": str(target_tif),
"backscatter_unit": "gamma_mli_power",
"warning": f"rasterio/numpy unavailable; kept power values: {exc}",
}
with rasterio.open(source_tif) as src:
data = src.read(1).astype("float32")
profile = src.profile.copy()
src_nodata = src.nodata
invalid = ~np.isfinite(data)
if src_nodata is not None:
invalid |= data == src_nodata
invalid |= data <= 0
db_data = np.full(data.shape, nodata_value, dtype="float32")
db_data[~invalid] = (10.0 * np.log10(data[~invalid])).astype("float32")
profile.update(dtype="float32", count=1, nodata=nodata_value, compress="deflate")
target_tif.parent.mkdir(parents=True, exist_ok=True)
with rasterio.open(target_tif, "w", **profile) as dst:
dst.write(db_data, 1)
return {"target": str(target_tif), "backscatter_unit": "gamma_mli_db"}
def main() -> int:
args = parse_args()
source_path = Path(args.source_path).resolve()
output_dir = Path(args.output_dir).resolve()
work_root = Path(args.work_dir).resolve()
pyint_home = Path(args.pyint_home).resolve()
dem_root = Path(args.dem_root).resolve()
project_name = re.sub(r"[^0-9A-Za-z._-]+", "_", args.project_name).strip("._-") or "sar_scene"
date = re.sub(r"\D", "", str(args.date or ""))[:8]
if not re.fullmatch(r"20\d{6}", date):
raise ValueError(f"Invalid scene date: {args.date}")
scratch_dir = work_root / "scratch"
template_dir = work_root / "templates"
project_dir = scratch_dir / project_name
download_dir = project_dir / "DOWNLOAD"
log_dir = output_dir / "logs"
output_dir.mkdir(parents=True, exist_ok=True)
dem_root.mkdir(parents=True, exist_ok=True)
env = os.environ.copy()
env["SCRATCHDIR"] = str(scratch_dir)
env["TEMPLATEDIR"] = str(template_dir)
env["DEMDIR"] = str(dem_root)
env["PYTHONPATH"] = f"{pyint_home}:{env.get('PYTHONPATH', '')}"
env["PATH"] = f"{pyint_home / 'pyint'}:{env.get('PATH', '')}"
staged_inputs = stage_lt_inputs(source_path, download_dir, date)
template_path = template_dir / f"{project_name}.template"
write_template(
template_path=template_path,
date=date,
range_looks=max(1, int(args.range_looks)),
azimuth_looks=max(1, int(args.azimuth_looks)),
geo_interp=str(args.geo_interp or "1"),
prepared_dem_path=args.prepared_dem_path,
)
commands: list[dict[str, Any]] = []
commands.append(
run_logged(
[sys.executable, str(pyint_home / "pyint" / "down2slc_LT1.py"), project_name, date],
cwd=work_root,
env=env,
log_dir=log_dir,
stage="down2slc_lt1",
)
)
commands.append(
run_logged(
[sys.executable, str(pyint_home / "pyint" / "generate_rdc_dem.py"), project_name],
cwd=work_root,
env=env,
log_dir=log_dir,
stage="generate_rdc_dem",
)
)
dem_dir = project_dir / "DEM"
range_looks = max(1, int(args.range_looks))
amp = dem_dir / f"{date}_{range_looks}rlks.amp"
amp_par = dem_dir / f"{date}_{range_looks}rlks.amp.par"
utm_dem_par = dem_dir / f"{date}_{range_looks}rlks.utm.dem.par"
utm_to_rdc = dem_dir / f"{date}_{range_looks}rlks.UTM_TO_RDC"
for required in (amp, amp_par, utm_dem_par, utm_to_rdc):
if not required.is_file():
raise FileNotFoundError(f"Required Gamma product missing: {required}")
width = read_gamma_par(amp_par, "range_samples")
geo_width = read_gamma_par(utm_dem_par, "width")
geo_nlines = read_gamma_par(utm_dem_par, "nlines")
geo_amp = output_dir / "gamma_geo_amp"
commands.append(
run_logged(
[
"geocode_back",
str(amp),
str(width),
str(utm_to_rdc),
str(geo_amp),
str(geo_width),
str(geo_nlines),
str(args.geo_interp or "1"),
"0",
],
cwd=work_root,
env=env,
log_dir=log_dir,
stage="geocode_amp",
)
)
power_tif = output_dir / "analysis_ready_power.tif"
commands.append(
run_logged(
[
"data2geotiff",
str(utm_dem_par),
str(geo_amp),
"2",
str(power_tif),
f"{float(args.nodata_value):g}",
],
cwd=work_root,
env=env,
log_dir=log_dir,
stage="data2geotiff_amp",
)
)
if not power_tif.is_file() or power_tif.stat().st_size <= 0:
raise RuntimeError(f"data2geotiff did not create output: {power_tif}")
final_tif = output_dir / "analysis_ready.tif"
conversion = convert_to_db_geotiff(power_tif, final_tif, float(args.nodata_value)) if args.to_db else {
"target": str(final_tif),
"backscatter_unit": "gamma_mli_power",
}
if not args.to_db:
shutil.copy2(power_tif, final_tif)
manifest = {
"ok": True,
"satellite_family": args.satellite_family,
"project_name": project_name,
"date": date,
"source_path": str(source_path),
"staged_inputs": staged_inputs,
"work_dir": str(work_root),
"output_dir": str(output_dir),
"analysis_tif_path": str(final_tif),
"power_tif_path": str(power_tif),
"backscatter_unit": conversion.get("backscatter_unit"),
"gamma_products": {
"amp": str(amp),
"amp_par": str(amp_par),
"utm_dem_par": str(utm_dem_par),
"utm_to_rdc": str(utm_to_rdc),
"geo_amp": str(geo_amp),
},
"looks": {"range": range_looks, "azimuth": max(1, int(args.azimuth_looks))},
"commands": commands,
"conversion": conversion,
}
manifest_path = output_dir / "manifest.json"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps({"ok": True, "manifest_path": str(manifest_path), "analysis_tif_path": str(final_tif)}))
return 0
if __name__ == "__main__":
raise SystemExit(main())
+2
View File
@@ -22,6 +22,7 @@ from . import (
ps_products,
radar,
root_registry,
sbas_insar_production,
stats,
task_batches,
tasks_runtime,
@@ -53,6 +54,7 @@ def include_all_routers(router: APIRouter) -> None:
router.include_router(dinsar.router)
router.include_router(dinsar_products.router)
router.include_router(dinsar_production.router)
router.include_router(sbas_insar_production.router)
router.include_router(timeseries_production.router)
router.include_router(ps_products.router)
router.include_router(ai.router)
+165 -58
View File
@@ -1,45 +1,65 @@
"""Flood disaster analysis router.
This router exposes the flood-analysis business API while reusing the
existing water/flood processing records and jobs during migration.
"""
"""Flood disaster analysis router."""
from __future__ import annotations
from typing import Optional
from typing import Any, Optional
from fastapi import APIRouter, Depends, HTTPException
from fastapi import APIRouter, Depends
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from ..database import get_db
from ..models import AuthUserORM
from . import water as water_compat
from ..services import flood_analysis_service
from ..services import flood_overlay_service
from ..services import flood_product_service
from .dependencies import _get_current_user, _require_admin
router = APIRouter()
class FloodPreprocessRequest(BaseModel):
radar_data_id: int = Field(..., description="RadarDataORM 主键")
radar_data_id: int = Field(..., description="RadarDataORM primary key")
class FloodWaterExtractionRequest(BaseModel):
scene_id: Optional[int] = Field(default=None, description="SARSceneGeoORM 主键")
input_path: Optional[str] = Field(default=None, description="直接指定 GeoTIFF/ENVI 路径")
scene_id: Optional[int] = Field(default=None, description="SARSceneGeoORM primary key")
input_path: Optional[str] = Field(default=None, description="Direct GeoTIFF/ENVI input path")
class FloodPairSearchRequest(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="最小重叠比例")
pre_start: Optional[str] = Field(default=None, description="Pre-flood start date in YYYYMMDD")
pre_end: Optional[str] = Field(default=None, description="Pre-flood end date in YYYYMMDD")
post_start: Optional[str] = Field(default=None, description="Post-flood start date in YYYYMMDD")
post_end: Optional[str] = Field(default=None, description="Post-flood end date in YYYYMMDD")
overlap_threshold: float = Field(default=0.3, ge=0.0, le=1.0, description="Minimum overlap ratio")
class FloodDisasterPairSearchRequest(BaseModel):
disaster_name: Optional[str] = Field(default=None, description="Disaster/event display name")
disaster_date: str = Field(..., description="Disaster date in YYYYMMDD")
region_tree_id: Optional[str] = Field(default=None, description="AOI admin region tree id")
aoi_geojson: Optional[dict[str, Any]] = Field(default=None, description="AOI GeoJSON FeatureCollection")
pre_window_days: int = Field(default=30, ge=1, le=365)
post_window_days: int = Field(default=30, ge=1, le=365)
min_aoi_coverage_ratio: float = Field(default=0.2, ge=0.0, le=1.0)
min_pair_overlap_ratio: float = Field(default=0.3, ge=0.0, le=1.0)
max_pairs: int = Field(default=50, ge=1, le=200)
satellites: Optional[list[str]] = None
polarization: Optional[str] = None
imaging_mode: Optional[str] = None
product_level: Optional[str] = None
require_same_polarization: bool = True
require_same_imaging_mode: bool = False
class FloodDetectionRequest(BaseModel):
pre_scene_id: int = Field(..., description="灾前 SARSceneGeoORM 主键")
post_scene_id: int = Field(..., description="灾后 SARSceneGeoORM 主键")
refine: bool = Field(default=False, description="是否启用 MRF 精化")
pre_scene_id: int = Field(..., description="Pre-flood SARSceneGeoORM primary key")
post_scene_id: int = Field(..., description="Post-flood SARSceneGeoORM primary key")
refine: bool = Field(default=False, description="Enable MRF refinement")
class FloodOverlayRequest(BaseModel):
near_threshold_m: float = Field(default=500.0, ge=0.0, le=10000.0, description="Near-flood threshold in meters")
@router.post("/flood/preprocess", status_code=202)
@@ -48,8 +68,7 @@ async def submit_flood_preprocess(
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
"""提交水体提取前置处理任务,当前复用旧 water geocode 链路。"""
return await water_compat.submit_geocode(req, db=db, admin_user=admin_user)
return await flood_analysis_service.submit_geocode_job(req, db=db)
@router.get("/flood/scenes")
@@ -59,8 +78,7 @@ async def list_flood_scenes(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
"""列出可作为水体提取输入的地理编码场景。"""
return await water_compat.list_scenes(limit=limit, offset=offset, db=db, current_user=current_user)
return await flood_analysis_service.list_scenes(limit=limit, offset=offset, db=db)
@router.get("/flood/scenes/done-radar-ids")
@@ -68,7 +86,7 @@ async def list_flood_done_radar_ids(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await water_compat.list_done_scene_radar_ids(db=db, current_user=current_user)
return await flood_analysis_service.list_done_scene_radar_ids(db=db)
@router.get("/flood/scenes/active-radar-ids")
@@ -76,7 +94,7 @@ async def list_flood_active_radar_ids(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await water_compat.list_active_scene_radar_ids(db=db, current_user=current_user)
return await flood_analysis_service.list_active_scene_radar_ids(db=db)
@router.post("/flood/scenes/{scene_id}/reset")
@@ -85,7 +103,7 @@ async def reset_flood_scene(
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
return await water_compat.reset_scene_status(scene_id=scene_id, db=db, admin_user=admin_user)
return await flood_analysis_service.reset_scene_status(scene_id=scene_id, db=db)
@router.post("/flood/water-extractions", status_code=202)
@@ -94,8 +112,7 @@ async def submit_flood_water_extraction(
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
"""提交单景水体提取任务,当前复用 Otsu 快速水体检测实现。"""
return await water_compat.submit_water_detect(req, db=db, admin_user=admin_user)
return await flood_analysis_service.submit_water_extraction(req, db=db)
@router.get("/flood/water-extractions")
@@ -106,12 +123,11 @@ async def list_flood_water_extractions(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await water_compat.list_water_detections(
return await flood_analysis_service.list_water_extractions(
limit=limit,
offset=offset,
status=status,
db=db,
current_user=current_user,
)
@@ -121,11 +137,7 @@ async def get_flood_water_extraction_preview(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await water_compat.get_water_detection_preview(
detection_id=extraction_id,
db=db,
current_user=current_user,
)
return await flood_analysis_service.get_water_extraction_preview(extraction_id=extraction_id, db=db)
@router.post("/flood/pairs/search")
@@ -134,7 +146,16 @@ async def search_flood_pairs(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await water_compat.find_water_pairs(req, db=db, current_user=current_user)
return await flood_analysis_service.search_pairs(req, db=db)
@router.post("/flood/disaster-pairs/search")
async def search_flood_disaster_pairs(
req: FloodDisasterPairSearchRequest,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_analysis_service.search_disaster_pairs(req, db=db)
@router.post("/flood/detections", status_code=202)
@@ -143,7 +164,7 @@ async def submit_flood_detection(
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
return await water_compat.submit_flood_detect(req, db=db, admin_user=admin_user)
return await flood_analysis_service.submit_flood_detection(req, db=db)
@router.get("/flood/detections")
@@ -151,7 +172,7 @@ async def list_flood_detections(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await water_compat.list_flood_events(db=db, current_user=current_user)
return await flood_analysis_service.list_flood_detections(db=db)
@router.get("/flood/detections/{detection_id}/preview/{layer}")
@@ -161,23 +182,109 @@ async def get_flood_detection_preview(
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
normalized = layer.strip().lower()
if normalized == "pre":
return await water_compat.flood_event_pre_preview(
event_id=detection_id,
db=db,
current_user=current_user,
)
if normalized == "post":
return await water_compat.flood_event_post_preview(
event_id=detection_id,
db=db,
current_user=current_user,
)
if normalized == "classified":
return await water_compat.flood_event_classified_preview(
event_id=detection_id,
db=db,
current_user=current_user,
)
raise HTTPException(status_code=404, detail=f"不支持的洪涝预览图层: {layer}")
return await flood_analysis_service.get_flood_detection_preview(
detection_id=detection_id,
layer=layer,
db=db,
)
@router.post("/flood/detections/{detection_id}/overlay", status_code=201)
async def run_flood_overlay(
detection_id: int,
req: FloodOverlayRequest | None = None,
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
threshold = req.near_threshold_m if req else 500.0
return await flood_overlay_service.run_overlay(
detection_id=detection_id,
db=db,
near_threshold_m=threshold,
)
@router.get("/flood/detections/{detection_id}/impact")
async def get_flood_impact(
detection_id: int,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_overlay_service.get_overlay_result(detection_id=detection_id, db=db)
@router.post("/flood/detections/{detection_id}/products", status_code=201)
async def create_flood_product(
detection_id: int,
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
return await flood_product_service.create_flood_product_for_detection(detection_id=detection_id, db=db)
@router.get("/flood/products")
async def list_flood_products(
limit: int = 20,
offset: int = 0,
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_product_service.list_flood_products(
db=db,
limit=limit,
offset=offset,
status=status,
)
@router.get("/flood/products/{product_id}")
async def get_flood_product(
product_id: str,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_product_service.get_flood_product(product_id_or_pk=product_id, db=db)
@router.get("/flood/products/{product_id}/manifest")
async def get_flood_product_manifest(
product_id: str,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_product_service.get_flood_product_manifest(product_id_or_pk=product_id, db=db)
@router.get("/flood/results")
async def list_flood_results(
limit: int = 20,
offset: int = 0,
status: Optional[str] = None,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_product_service.list_flood_products(
db=db,
limit=limit,
offset=offset,
status=status,
)
@router.get("/flood/results/{product_id}")
async def get_flood_result(
product_id: str,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_product_service.get_flood_product(product_id_or_pk=product_id, db=db)
@router.get("/flood/results/{product_id}/manifest")
async def get_flood_result_manifest(
product_id: str,
db: AsyncSession = Depends(get_db),
current_user: AuthUserORM = Depends(_get_current_user),
):
return await flood_product_service.get_flood_product_manifest(product_id_or_pk=product_id, db=db)
+64 -2
View File
@@ -3,11 +3,11 @@ from __future__ import annotations
from typing import List, Optional
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from pydantic import BaseModel
from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.future import select
from ..config import read_int_env
from ..config import read_int_env, settings, split_env_paths
from ..database import get_db
from ..models import AuthUserORM, SystemTaskORM, TaskLogORM
from ..scheduler import MONITOR_CONFIG
@@ -41,11 +41,23 @@ class MonitorConfig(BaseModel):
radar_dirs: List[str] = []
orbit_dir: Optional[str] = None
dinsar_dirs: List[str] = []
gf3_archive_source_dirs: List[str] = []
gf3_source_dirs: List[str] = []
gf3_storage_dirs: List[str] = []
# Manual-only: config is read from .env
class GF3UnpackConfig(BaseModel):
source_dirs: List[str] = []
target_dirs: List[str] = []
archive_exts: List[str] = []
delete_archive: bool = False
class GF3UnpackRunRequest(BaseModel):
max_files_per_run: Optional[int] = Field(default=None, ge=0)
@router.post("/monitor/config")
async def update_monitor_config(config: MonitorConfig):
"""
@@ -142,6 +154,56 @@ async def run_gf3_batch_process(admin_user: AuthUserORM = Depends(_require_admin
raise HTTPException(status_code=409, detail=str(e))
@router.get("/monitor/gf3-unpack/config")
async def get_gf3_unpack_config(admin_user: AuthUserORM = Depends(_require_admin)):
return GF3UnpackConfig(
source_dirs=MONITOR_CONFIG.get("gf3_archive_source_dirs") or [],
target_dirs=MONITOR_CONFIG.get("gf3_source_dirs") or [],
archive_exts=split_env_paths(settings.GF3_ARCHIVE_EXTS),
delete_archive=bool(settings.GF3_UNPACK_DELETE_ARCHIVE),
)
@router.post("/monitor/gf3-unpack", status_code=202)
async def run_gf3_unpack(
request_data: GF3UnpackRunRequest | None = None,
admin_user: AuthUserORM = Depends(_require_admin),
):
"""
将 GF3 压缩包池解包到 GF3_SOURCE_DIRS,作为后续 L1A→L2 预处理输入。
"""
gf3_archive_source_dirs = MONITOR_CONFIG.get("gf3_archive_source_dirs") or []
gf3_source_dirs = MONITOR_CONFIG.get("gf3_source_dirs") or []
if not gf3_archive_source_dirs:
raise HTTPException(status_code=400, detail="GF3_ARCHIVE_SOURCE_DIRS is not configured.")
if not gf3_source_dirs:
raise HTTPException(status_code=400, detail="GF3_SOURCE_DIRS is not configured.")
max_files = None
if request_data is not None and request_data.max_files_per_run is not None:
max_files = max(0, int(request_data.max_files_per_run))
task_type = "GF3_UNPACK"
task_name = "GF3 压缩包解包"
payload = {
"source_dirs": gf3_archive_source_dirs,
"target_dirs": gf3_source_dirs,
"archive_exts": split_env_paths(settings.GF3_ARCHIVE_EXTS),
}
if max_files is not None:
payload["max_files_per_run"] = max_files
try:
task_id = await task_service.create_task(task_type, task_name, params=payload)
await job_queue_service.create_job(task_type, payload=payload, task_id=task_id)
return {
"message": "GF3 解包任务已提交",
"task_id": task_id,
}
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
@router.get("/monitor/status")
async def get_monitor_status():
"""
@@ -0,0 +1,323 @@
from __future__ import annotations
import asyncio
import mimetypes
import subprocess
from fastapi import APIRouter, HTTPException
from fastapi.responses import FileResponse
from pydantic import BaseModel, Field, field_validator
from ..services.job_queue_service import job_queue_service
from ..services.sbas_insar_production_service import sbas_insar_production_service
from ..services.task_service import task_service
router = APIRouter(prefix="/sbas-insar-production", tags=["sbas-insar-production"])
class SbasStackDiscoverRequest(BaseModel):
source_roots: list[str] | None = None
orbit_roots: list[str] | None = None
min_scenes: int = Field(default=3, ge=2, le=100)
require_orbits: bool = True
include_scenes: bool = False
limit: int = Field(default=30, ge=0, le=500)
platform: str | None = Field(default=None, max_length=16)
relative_orbit: str | None = Field(default=None, max_length=32)
orbit_direction: str | None = Field(default=None, max_length=32)
@field_validator("source_roots", "orbit_roots", mode="before")
@classmethod
def _normalize_roots(cls, value):
if value is None:
return None
if isinstance(value, str):
items = [value]
else:
items = list(value)
cleaned = [str(item or "").strip() for item in items if str(item or "").strip()]
return cleaned or None
@field_validator("platform", "relative_orbit", "orbit_direction", mode="before")
@classmethod
def _normalize_optional_text(cls, value):
if value is None:
return None
text = str(value).strip()
return text or None
class SbasMonitorPoint(BaseModel):
point_id: str | None = Field(default=None, max_length=64)
label: str | None = Field(default=None, max_length=120)
lon: float = Field(ge=-180, le=180)
lat: float = Field(ge=-90, le=90)
class SbasRunSubmitRequest(SbasStackDiscoverRequest):
run_label: str | None = Field(default=None, max_length=120)
dry_run: bool = True
monitor_point_strategy: str = Field(default="auto_low_sigma_high_rate", max_length=64)
monitor_points: list[SbasMonitorPoint] | None = None
class SbasBaselineAuditRequest(BaseModel):
execute: bool = True
rlks: int = Field(default=8, ge=1, le=64)
azlks: int = Field(default=8, ge=1, le=64)
max_delta_n: int = Field(default=1, ge=1, le=100)
timeout_seconds: int = Field(default=21600, ge=60, le=86400)
class SbasItabDecisionRequest(BaseModel):
decision: str = Field(pattern="^(approve|reject)$")
reviewer: str | None = Field(default=None, max_length=120)
note: str | None = Field(default=None, max_length=1000)
class SbasCoregistrationRequest(BaseModel):
execute: bool = False
rlks: int = Field(default=8, ge=1, le=64)
azlks: int = Field(default=8, ge=1, le=64)
class SbasCoregistrationJobRequest(BaseModel):
rlks: int = Field(default=8, ge=1, le=64)
azlks: int = Field(default=8, ge=1, le=64)
timeout_seconds: int = Field(default=43200, ge=60, le=172800)
@router.get("/capabilities")
async def get_sbas_insar_capabilities():
return sbas_insar_production_service.get_capabilities()
@router.post("/stacks/discover")
async def discover_sbas_insar_stacks(request: SbasStackDiscoverRequest):
try:
return await asyncio.to_thread(
sbas_insar_production_service.discover_stacks,
source_roots=request.source_roots,
orbit_roots=request.orbit_roots,
min_scenes=request.min_scenes,
require_orbits=request.require_orbits,
include_scenes=request.include_scenes,
limit=request.limit,
platform=request.platform,
relative_orbit=request.relative_orbit,
orbit_direction=request.orbit_direction,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/stacks/{stack_id}/audit")
async def audit_sbas_insar_stack(stack_id: str, request: SbasStackDiscoverRequest):
try:
return await asyncio.to_thread(
sbas_insar_production_service.audit_stack,
stack_id,
source_roots=request.source_roots,
orbit_roots=request.orbit_roots,
min_scenes=request.min_scenes,
require_orbits=request.require_orbits,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/stacks/{stack_id}/runs", status_code=202)
async def submit_sbas_insar_run(stack_id: str, request: SbasRunSubmitRequest):
try:
return await asyncio.to_thread(
sbas_insar_production_service.create_run,
stack_id,
run_label=request.run_label,
source_roots=request.source_roots,
orbit_roots=request.orbit_roots,
min_scenes=request.min_scenes,
require_orbits=request.require_orbits,
monitor_points=[
point.model_dump(exclude_none=True)
for point in (request.monitor_points or [])
],
monitor_point_strategy=request.monitor_point_strategy,
dry_run=request.dry_run,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/runs")
async def list_sbas_insar_runs():
return await asyncio.to_thread(sbas_insar_production_service.list_runs)
@router.get("/runs/{run_id}")
async def get_sbas_insar_run(run_id: str):
try:
return await asyncio.to_thread(sbas_insar_production_service.get_run_detail, run_id)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/runs/{run_id}/baseline-audit", status_code=202)
async def run_sbas_insar_baseline_audit(run_id: str, request: SbasBaselineAuditRequest):
try:
return await asyncio.to_thread(
sbas_insar_production_service.run_baseline_audit,
run_id,
execute=request.execute,
rlks=request.rlks,
azlks=request.azlks,
max_delta_n=request.max_delta_n,
timeout_seconds=request.timeout_seconds,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except subprocess.TimeoutExpired as exc:
raise HTTPException(status_code=504, detail=f"baseline audit timed out after {exc.timeout}s") from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/runs/{run_id}/itab-decision")
async def decide_sbas_insar_itab(run_id: str, request: SbasItabDecisionRequest):
try:
return await asyncio.to_thread(
sbas_insar_production_service.decide_itab,
run_id,
decision=request.decision,
reviewer=request.reviewer,
note=request.note,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/runs/{run_id}/coregistration", status_code=202)
async def prepare_sbas_insar_coregistration(run_id: str, request: SbasCoregistrationRequest):
try:
return await asyncio.to_thread(
sbas_insar_production_service.prepare_coregistration,
run_id,
execute=request.execute,
rlks=request.rlks,
azlks=request.azlks,
)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/runs/{run_id}/coregistration/jobs", status_code=202)
async def submit_sbas_insar_coregistration_job(run_id: str, request: SbasCoregistrationJobRequest):
try:
run_detail = await asyncio.to_thread(sbas_insar_production_service.get_run_detail, run_id)
status = str((run_detail.get("run") or {}).get("status") or "").strip()
if status in {"ITAB_APPROVED", "COREGISTRATION_FAILED"}:
await asyncio.to_thread(
sbas_insar_production_service.prepare_coregistration,
run_id,
execute=False,
rlks=request.rlks,
azlks=request.azlks,
)
run_detail = await asyncio.to_thread(sbas_insar_production_service.get_run_detail, run_id)
status = str((run_detail.get("run") or {}).get("status") or "").strip()
if status not in {"COREGISTRATION_SCRIPT_READY", "COREGISTRATION_RUNNING"}:
raise ValueError(f"run status does not allow coregistration job submission: {status}")
if status == "COREGISTRATION_RUNNING":
raise ValueError("coregistration is already running for this run")
from ..services.job_handlers import JOB_TYPE_SBAS_COREGISTRATION
payload = {
"run_id": run_id,
"rlks": request.rlks,
"azlks": request.azlks,
"timeout_seconds": request.timeout_seconds,
}
task_id = await task_service.create_task(
task_type=JOB_TYPE_SBAS_COREGISTRATION,
task_name=f"SBAS-InSAR 共参考配准: {run_id}",
params=payload,
)
job_id = await job_queue_service.create_job(
job_type=JOB_TYPE_SBAS_COREGISTRATION,
payload=payload,
task_id=task_id,
max_attempts=1,
)
return {
"message": "SBAS-InSAR coregistration job queued.",
"run_id": run_id,
"task_id": task_id,
"job_id": job_id,
"job_type": JOB_TYPE_SBAS_COREGISTRATION,
}
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
message = str(exc)
status_code = 409 if "冲突" in message or "conflict" in message.lower() else 400
raise HTTPException(status_code=status_code, detail=message) from exc
@router.get("/runs/{run_id}/artifacts/{relative_path:path}")
async def get_sbas_insar_run_artifact(run_id: str, relative_path: str):
try:
artifact_path = sbas_insar_production_service.resolve_run_artifact_path(run_id, relative_path)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
media_type = mimetypes.guess_type(str(artifact_path))[0] or "application/octet-stream"
return FileResponse(
artifact_path,
media_type=media_type,
filename=artifact_path.name,
)
@router.get("/trial-runs")
async def list_sbas_insar_trial_runs():
return await asyncio.to_thread(sbas_insar_production_service.list_trial_runs)
@router.get("/trial-runs/{trial_id}")
async def get_sbas_insar_trial_run(trial_id: str):
try:
return await asyncio.to_thread(sbas_insar_production_service.get_trial_detail, trial_id)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.get("/trial-runs/{trial_id}/artifacts/{relative_path:path}")
async def get_sbas_insar_artifact(trial_id: str, relative_path: str):
try:
artifact_path = sbas_insar_production_service.resolve_artifact_path(trial_id, relative_path)
except FileNotFoundError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
media_type = mimetypes.guess_type(str(artifact_path))[0] or "application/octet-stream"
return FileResponse(
artifact_path,
media_type=media_type,
filename=artifact_path.name,
)
+1
View File
@@ -19,6 +19,7 @@ MONITOR_CONFIG = {
"s1_storage_dirs": split_env_paths(settings.SENTINEL1_STORAGE_DIRS),
"s1_orbit_dirs": split_env_paths(settings.ORBIT_SOURCE_DIRS),
# GF3 链路
"gf3_archive_source_dirs": split_env_paths(settings.GF3_ARCHIVE_SOURCE_DIRS),
"gf3_source_dirs": split_env_paths(settings.GF3_SOURCE_DIRS),
"gf3_storage_dirs": split_env_paths(settings.GF3_STORAGE_DIRS),
"mode": "manual",
@@ -0,0 +1,967 @@
"""Flood-analysis service layer.
This module owns the flood business API implementation used by
``backend.app.routers.flood``. It intentionally does not import the legacy
water router; the old router remains only as a compatibility surface.
"""
from __future__ import annotations
import asyncio
import base64
import io
import json
import os
from datetime import datetime, timedelta
from typing import Any
from fastapi import HTTPException
from fastapi.responses import JSONResponse
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..models import FloodDetectionORM, RadarDataORM, SARSceneGeoORM, WaterDetectionORM, WaterExtractionORM
from ..services.job_handlers import (
JOB_TYPE_FLOOD_DETECTION,
JOB_TYPE_SAR_SCENE_PREPROCESS,
JOB_TYPE_WATER_DETECT,
)
from ..services.job_queue_service import job_queue_service
from ..services.task_service import task_service
from ..utils import normalize_satellite_family
_FLOOD_JOB_MAX_ATTEMPTS = 3
async def _queue_flood_job(
*,
job_type: str,
task_type: str,
task_name: str,
payload: dict[str, Any],
) -> dict[str, Any]:
try:
task_id = await task_service.create_task(
task_type=task_type,
task_name=task_name,
params=payload,
)
job_id = await job_queue_service.create_job(
job_type=job_type,
payload=payload,
task_id=task_id,
max_attempts=_FLOOD_JOB_MAX_ATTEMPTS,
)
return {"task_id": task_id, "job_id": job_id, "job_type": job_type, "message": "Job queued."}
except ValueError as exc:
message = str(exc)
raise HTTPException(status_code=409 if "conflict" in message.lower() else 400, detail=message) from exc
def _overlap_ratio(poly_a: Any, poly_b: Any) -> float:
try:
import json
from shapely.geometry import Polygon, shape
def _to_geom(poly: Any):
if isinstance(poly, str):
poly = json.loads(poly)
if isinstance(poly, list):
return Polygon(poly)
return shape(poly)
a = _to_geom(poly_a)
b = _to_geom(poly_b)
if not a.is_valid or not b.is_valid:
return 0.0
intersection_area = a.intersection(b).area
smaller_area = min(a.area, b.area)
return intersection_area / smaller_area if smaller_area > 0 else 0.0
except Exception:
return 0.0
def _parse_ymd(value: str | None, *, field: str) -> datetime:
try:
normalized = str(value or "").replace("-", "").strip()
return datetime.strptime(normalized, "%Y%m%d")
except Exception as exc:
raise HTTPException(status_code=400, detail=f"{field} must be YYYYMMDD") from exc
def _format_ymd(value: datetime) -> str:
return value.strftime("%Y%m%d")
def _to_float(value: Any, default: float = 0.0) -> float:
try:
if value is None:
return default
return float(value)
except Exception:
return default
def _same_text(left: Any, right: Any) -> bool:
left_text = str(left or "").strip().lower()
right_text = str(right or "").strip().lower()
if not left_text or not right_text:
return True
return left_text == right_text
def _feature_collection_name(feature_collection: dict[str, Any]) -> str | None:
try:
features = feature_collection.get("features") or []
properties = features[0].get("properties") or {}
return properties.get("name") or properties.get("NAME") or properties.get("treeID")
except Exception:
return None
def _preprocess_engine_for_radar(radar: RadarDataORM) -> str | None:
family = str(normalize_satellite_family(radar.satellite_family or radar.satellite) or "").upper()
if family == "GF3":
return "gf3_gdal"
if family == "LT1":
return "lt_gamma"
return None
def _scene_analysis_path(scene: SARSceneGeoORM | None) -> str | None:
if not scene:
return None
return scene.analysis_tif_path
def _resolve_aoi_wkt_from_request(req: Any) -> tuple[str, dict[str, Any], dict[str, Any]]:
"""Resolve region/GeoJSON AOI using the same parser as the management search page."""
aoi_geojson = getattr(req, "aoi_geojson", None)
region_tree_id = getattr(req, "region_tree_id", None)
if aoi_geojson:
feature_collection = aoi_geojson
source = "geojson"
elif region_tree_id:
from ..routers.dependencies import _resolve_region_aoi_payload
payload = _resolve_region_aoi_payload(str(region_tree_id))
feature_collection = payload.get("aoi_geojson") or payload
source = "region"
else:
raise HTTPException(status_code=400, detail="region_tree_id or aoi_geojson is required")
from ..routers.dependencies import _parse_aoi_geojson_form_value
parsed = _parse_aoi_geojson_form_value(json.dumps(feature_collection, ensure_ascii=False))
if not parsed:
raise HTTPException(status_code=400, detail="AOI geometry is empty")
aoi_wkt, normalized_feature_collection = parsed
meta = {
"source": source,
"region_tree_id": region_tree_id,
"name": _feature_collection_name(normalized_feature_collection),
}
return aoi_wkt, normalized_feature_collection, meta
def _radar_scene_item(scene: SARSceneGeoORM, radar: RadarDataORM, *, aoi_coverage_ratio: float | None = None) -> dict[str, Any]:
return {
"id": scene.id,
"scene_id": scene.id,
"radar_data_id": scene.radar_data_id,
"satellite": radar.satellite,
"imaging_date": radar.imaging_date,
"acquisition_time_utc": radar.acquisition_time_utc,
"imaging_mode": radar.imaging_mode,
"product_level": radar.product_level,
"polarization": radar.polarization,
"orbit_direction": radar.orbit_direction,
"geo_path": scene.geo_path,
"analysis_tif_path": scene.analysis_tif_path,
"analysis_dir": scene.analysis_dir,
"analysis_preview_path": scene.analysis_preview_path,
"analysis_engine": scene.analysis_engine,
"analysis_profile": scene.analysis_profile,
"analysis_backscatter_unit": scene.analysis_backscatter_unit,
"analysis_quality_json": scene.analysis_quality_json,
"coverage_polygon": radar.coverage_polygon,
"min_lat": radar.min_lat,
"max_lat": radar.max_lat,
"min_lon": radar.min_lon,
"max_lon": radar.max_lon,
"aoi_coverage_ratio": aoi_coverage_ratio,
}
async def _query_disaster_scene_pool(
*,
db: AsyncSession,
req: Any,
aoi_wkt: str,
start_ymd: str,
end_ymd: str,
min_aoi_coverage_ratio: float,
descending: bool,
) -> list[dict[str, Any]]:
aoi_geom = func.ST_GeomFromText(aoi_wkt, 4326)
aoi_area = func.ST_Area(func.Geography(aoi_geom))
coverage_expr = (
func.ST_Area(func.Geography(func.ST_Intersection(RadarDataORM.geom, aoi_geom)))
/ func.nullif(aoi_area, 0)
).label("aoi_coverage_ratio")
filters = [
SARSceneGeoORM.status == "DONE",
SARSceneGeoORM.analysis_tif_path.isnot(None),
RadarDataORM.geom.isnot(None),
RadarDataORM.imaging_date.isnot(None),
RadarDataORM.imaging_date >= start_ymd,
RadarDataORM.imaging_date <= end_ymd,
func.ST_Intersects(RadarDataORM.geom, aoi_geom),
]
satellites = [str(item).strip() for item in (getattr(req, "satellites", None) or []) if str(item).strip()]
if satellites:
filters.append(RadarDataORM.satellite.in_(satellites))
polarization = str(getattr(req, "polarization", "") or "").strip()
if polarization:
filters.append(RadarDataORM.polarization.ilike(f"%{polarization}%"))
imaging_mode = str(getattr(req, "imaging_mode", "") or "").strip()
if imaging_mode:
filters.append(RadarDataORM.imaging_mode == imaging_mode)
product_level = str(getattr(req, "product_level", "") or "").strip()
if product_level:
filters.append(RadarDataORM.product_level == product_level)
order_by = RadarDataORM.imaging_date.desc() if descending else RadarDataORM.imaging_date.asc()
result = await db.execute(
select(SARSceneGeoORM, RadarDataORM, coverage_expr)
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(*filters)
.order_by(order_by, SARSceneGeoORM.id.desc())
)
pool: list[dict[str, Any]] = []
for scene, radar, aoi_coverage_ratio in result.all():
coverage_ratio = max(0.0, min(1.0, _to_float(aoi_coverage_ratio)))
if coverage_ratio < min_aoi_coverage_ratio:
continue
pool.append(_radar_scene_item(scene, radar, aoi_coverage_ratio=round(coverage_ratio, 4)))
return pool
async def submit_geocode_job(req: Any, db: AsyncSession) -> dict[str, Any]:
radar = await db.get(RadarDataORM, req.radar_data_id)
if not radar:
raise HTTPException(status_code=404, detail=f"RadarData id={req.radar_data_id} not found")
result = await db.execute(
select(SARSceneGeoORM)
.where(SARSceneGeoORM.radar_data_id == req.radar_data_id)
.with_for_update(skip_locked=True)
)
scene = result.scalar_one_or_none()
if scene and scene.status in ("PENDING", "RUNNING"):
raise HTTPException(status_code=409, detail="Scene already has an active geocode job")
if not scene:
scene = SARSceneGeoORM(radar_data_id=req.radar_data_id, status="PENDING")
db.add(scene)
await db.flush()
else:
scene.status = "PENDING"
scene.error_msg = None
await db.flush()
scene_id = scene.id
await db.commit()
engine = _preprocess_engine_for_radar(radar)
if not engine:
async with db.begin():
failed_scene = await db.get(SARSceneGeoORM, scene_id)
if failed_scene and failed_scene.status == "PENDING":
failed_scene.status = "FAILED"
failed_scene.error_msg = "No analysis-ready GeoTIFF preprocessor configured for this satellite"
raise HTTPException(
status_code=400,
detail="洪涝模块不再使用 ENVI 兜底预处理;该卫星暂未配置 analysis-ready GeoTIFF 预处理器",
)
job_type = JOB_TYPE_SAR_SCENE_PREPROCESS
task_type = f"FLOOD_SCENE_PREPROCESS_{scene_id}"
task_name = f"Flood analysis-ready preprocess radar_id={req.radar_data_id} engine={engine}"
payload = {"scene_id": scene_id, "radar_data_id": req.radar_data_id}
payload["engine"] = engine
try:
return await _queue_flood_job(
job_type=job_type,
task_type=task_type,
task_name=task_name,
payload=payload,
)
except HTTPException:
async with db.begin():
failed_scene = await db.get(SARSceneGeoORM, scene_id)
if failed_scene and failed_scene.status == "PENDING":
failed_scene.status = "FAILED"
failed_scene.error_msg = "Job queue failed"
raise
async def reset_scene_status(scene_id: int, db: AsyncSession) -> dict[str, Any]:
scene = await db.get(SARSceneGeoORM, scene_id)
if not scene:
raise HTTPException(status_code=404, detail=f"Scene id={scene_id} not found")
if scene.status not in ("PENDING", "RUNNING"):
raise HTTPException(status_code=400, detail=f"Scene status is {scene.status}; reset is not needed")
scene.status = "FAILED"
scene.error_msg = "Manually reset"
await db.commit()
return {"id": scene_id, "status": "FAILED", "message": "Scene reset"}
async def list_done_scene_radar_ids(db: AsyncSession) -> dict[str, list[int]]:
result = await db.execute(
select(SARSceneGeoORM.radar_data_id).where(
SARSceneGeoORM.status == "DONE",
SARSceneGeoORM.analysis_tif_path.isnot(None),
)
)
return {"ids": [row for (row,) in result.all()]}
async def list_active_scene_radar_ids(db: AsyncSession) -> dict[str, list[int]]:
result = await db.execute(
select(SARSceneGeoORM.radar_data_id).where(SARSceneGeoORM.status.in_(["PENDING", "RUNNING"]))
)
return {"ids": [row for (row,) in result.all()]}
async def list_scenes(limit: int, offset: int, db: AsyncSession) -> dict[str, Any]:
total_result = await db.execute(select(func.count()).select_from(SARSceneGeoORM))
total = total_result.scalar_one()
result = await db.execute(
select(SARSceneGeoORM, RadarDataORM)
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.order_by(SARSceneGeoORM.id.desc())
.limit(limit)
.offset(offset)
)
items = []
for scene, radar in result.all():
items.append(
{
"id": scene.id,
"radar_data_id": scene.radar_data_id,
"satellite": radar.satellite,
"imaging_date": radar.imaging_date,
"acquisition_time_utc": radar.acquisition_time_utc,
"imaging_mode": radar.imaging_mode,
"product_level": radar.product_level,
"polarization": radar.polarization,
"orbit_direction": radar.orbit_direction,
"geo_path": scene.geo_path,
"analysis_tif_path": scene.analysis_tif_path,
"analysis_dir": scene.analysis_dir,
"analysis_preview_path": scene.analysis_preview_path,
"analysis_engine": scene.analysis_engine,
"analysis_profile": scene.analysis_profile,
"analysis_backscatter_unit": scene.analysis_backscatter_unit,
"analysis_nodata_value": scene.analysis_nodata_value,
"analysis_metadata_json": scene.analysis_metadata_json,
"analysis_quality_json": scene.analysis_quality_json,
"pixel_size_m": scene.pixel_size_m,
"status": scene.status,
"error_msg": scene.error_msg,
"created_at": scene.created_at.isoformat() if scene.created_at else None,
"coverage_polygon": radar.coverage_polygon,
"min_lat": radar.min_lat,
"max_lat": radar.max_lat,
"min_lon": radar.min_lon,
"max_lon": radar.max_lon,
}
)
return {"items": items, "total": total}
async def submit_water_extraction(req: Any, db: AsyncSession) -> dict[str, Any]:
input_path = req.input_path
scene_id = req.scene_id
if scene_id:
scene = await db.get(SARSceneGeoORM, scene_id)
if not scene:
raise HTTPException(status_code=404, detail=f"SARSceneGeoORM id={scene_id} not found")
input_path = _scene_analysis_path(scene)
if not input_path:
raise HTTPException(status_code=400, detail="Scene has no analysis-ready GeoTIFF")
if not input_path:
raise HTTPException(status_code=400, detail="scene_id or input_path is required")
extraction = WaterExtractionORM(
scene_id=scene_id,
processor=getattr(req, "processor", None) or "otsu",
input_path=input_path,
status="PENDING",
)
db.add(extraction)
await db.flush()
extraction_id = extraction.id
await db.commit()
try:
queued = await _queue_flood_job(
job_type=JOB_TYPE_WATER_DETECT,
task_type=f"FLOOD_WATER_EXTRACTION_{extraction_id}",
task_name=f"Flood water extraction id={extraction_id}",
payload={"extraction_id": extraction_id, "processor": extraction.processor},
)
async with db.begin():
queued_extraction = await db.get(WaterExtractionORM, extraction_id)
if queued_extraction:
queued_extraction.task_id = queued.get("task_id")
return queued
except HTTPException:
async with db.begin():
failed_extraction = await db.get(WaterExtractionORM, extraction_id)
if failed_extraction and failed_extraction.status == "PENDING":
failed_extraction.status = "FAILED"
failed_extraction.error_msg = "Job queue failed"
raise
async def list_water_extractions(
*,
limit: int,
offset: int,
status: str | None,
db: AsyncSession,
) -> dict[str, Any]:
count_query = select(func.count()).select_from(WaterExtractionORM)
if status:
count_query = count_query.where(WaterExtractionORM.status == status)
total = (await db.execute(count_query)).scalar_one()
query = (
select(WaterExtractionORM, SARSceneGeoORM, RadarDataORM)
.outerjoin(SARSceneGeoORM, WaterExtractionORM.scene_id == SARSceneGeoORM.id)
.outerjoin(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.order_by(WaterExtractionORM.id.desc())
.limit(limit)
.offset(offset)
)
if status:
query = query.where(WaterExtractionORM.status == status)
rows = (await db.execute(query)).all()
items = []
for detection, scene, radar in rows:
items.append(
{
"id": detection.id,
"scene_id": detection.scene_id,
"processor": detection.processor,
"task_id": detection.task_id,
"radar_data_id": scene.radar_data_id if scene else None,
"satellite": radar.satellite if radar else None,
"imaging_date": radar.imaging_date if radar else None,
"acquisition_time_utc": radar.acquisition_time_utc if radar else None,
"imaging_mode": radar.imaging_mode if radar else None,
"product_level": radar.product_level if radar else None,
"polarization": radar.polarization if radar else None,
"orbit_direction": radar.orbit_direction if radar else None,
"coverage_polygon": radar.coverage_polygon if radar else None,
"min_lat": radar.min_lat if radar else None,
"max_lat": radar.max_lat if radar else None,
"min_lon": radar.min_lon if radar else None,
"max_lon": radar.max_lon if radar else None,
"input_path": detection.input_path,
"output_path": detection.output_path,
"preview_path": detection.preview_path,
"vector_path": detection.vector_path,
"water_area_km2": detection.water_area_km2,
"water_pixel_count": detection.water_pixel_count,
"otsu_threshold_db": detection.threshold_value,
"threshold_value": detection.threshold_value,
"metadata_json": detection.metadata_json,
"status": detection.status,
"error_msg": detection.error_msg,
"created_at": detection.created_at.isoformat() if detection.created_at else None,
"updated_at": detection.updated_at.isoformat() if detection.updated_at else None,
}
)
return {"items": items, "total": total}
async def submit_flood_detection(req: Any, db: AsyncSession) -> dict[str, Any]:
pre_scene = await db.get(SARSceneGeoORM, req.pre_scene_id)
post_scene = await db.get(SARSceneGeoORM, req.post_scene_id)
if not pre_scene:
raise HTTPException(status_code=404, detail=f"Pre-scene id={req.pre_scene_id} not found")
if not post_scene:
raise HTTPException(status_code=404, detail=f"Post-scene id={req.post_scene_id} not found")
if pre_scene.status != "DONE":
raise HTTPException(status_code=400, detail=f"Pre-scene is not DONE: {pre_scene.status}")
if post_scene.status != "DONE":
raise HTTPException(status_code=400, detail=f"Post-scene is not DONE: {post_scene.status}")
if not pre_scene.analysis_tif_path:
raise HTTPException(status_code=400, detail="Pre-scene has no analysis-ready GeoTIFF")
if not post_scene.analysis_tif_path:
raise HTTPException(status_code=400, detail="Post-scene has no analysis-ready GeoTIFF")
result = await db.execute(
select(FloodDetectionORM)
.where(
FloodDetectionORM.pre_scene_id == req.pre_scene_id,
FloodDetectionORM.post_scene_id == req.post_scene_id,
)
.with_for_update(skip_locked=True)
)
detection = result.scalar_one_or_none()
if detection and detection.status in ("PENDING", "RUNNING"):
raise HTTPException(status_code=409, detail="Pair already has an active flood-detection job")
if not detection:
detection = FloodDetectionORM(
pre_scene_id=req.pre_scene_id,
post_scene_id=req.post_scene_id,
status="PENDING",
)
db.add(detection)
await db.flush()
else:
detection.status = "PENDING"
detection.error_msg = None
await db.flush()
detection_id = detection.id
await db.commit()
try:
return await _queue_flood_job(
job_type=JOB_TYPE_FLOOD_DETECTION,
task_type=f"FLOOD_DETECTION_{detection_id}",
task_name=f"GeoTIFF flood detection pre={req.pre_scene_id} post={req.post_scene_id}",
payload={"detection_id": detection_id, "refine": req.refine},
)
except HTTPException:
async with db.begin():
failed_detection = await db.get(FloodDetectionORM, detection_id)
if failed_detection and failed_detection.status == "PENDING":
failed_detection.status = "FAILED"
failed_detection.error_msg = "Job queue failed"
raise
async def list_flood_detections(db: AsyncSession) -> dict[str, Any]:
result = await db.execute(
select(FloodDetectionORM)
.options(
selectinload(FloodDetectionORM.pre_scene).selectinload(SARSceneGeoORM.radar_data),
selectinload(FloodDetectionORM.post_scene).selectinload(SARSceneGeoORM.radar_data),
)
.order_by(FloodDetectionORM.id.desc())
)
detections = result.scalars().all()
items = []
for detection in detections:
pre_radar = detection.pre_scene.radar_data if detection.pre_scene else None
post_radar = detection.post_scene.radar_data if detection.post_scene else None
items.append(
{
"id": detection.id,
"pre_scene_id": detection.pre_scene_id,
"post_scene_id": detection.post_scene_id,
"pre_imaging_date": pre_radar.imaging_date if pre_radar else None,
"post_imaging_date": post_radar.imaging_date if post_radar else None,
"pre_satellite": pre_radar.satellite if pre_radar else None,
"post_satellite": post_radar.satellite if post_radar else None,
"pre_geo_path": _scene_analysis_path(detection.pre_scene),
"post_geo_path": _scene_analysis_path(detection.post_scene),
"pre_analysis_tif_path": _scene_analysis_path(detection.pre_scene),
"post_analysis_tif_path": _scene_analysis_path(detection.post_scene),
"classified_path": detection.classified_path,
"flood_area_km2": detection.flood_area_km2,
"stable_water_area_km2": detection.stable_water_area_km2,
"status": detection.status,
"error_msg": detection.error_msg,
"created_at": detection.created_at.isoformat() if detection.created_at else None,
"updated_at": detection.updated_at.isoformat() if detection.updated_at else None,
}
)
return {"items": items, "total": len(items)}
async def search_pairs(req: Any, db: AsyncSession) -> dict[str, Any]:
pre_filters = [SARSceneGeoORM.status == "DONE", SARSceneGeoORM.analysis_tif_path.isnot(None)]
if req.pre_start:
pre_filters.append(RadarDataORM.imaging_date >= req.pre_start)
if req.pre_end:
pre_filters.append(RadarDataORM.imaging_date <= req.pre_end)
pre_result = await db.execute(
select(SARSceneGeoORM, RadarDataORM)
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(*pre_filters)
)
post_filters = [SARSceneGeoORM.status == "DONE", SARSceneGeoORM.analysis_tif_path.isnot(None)]
if req.post_start:
post_filters.append(RadarDataORM.imaging_date >= req.post_start)
if req.post_end:
post_filters.append(RadarDataORM.imaging_date <= req.post_end)
post_result = await db.execute(
select(SARSceneGeoORM, RadarDataORM)
.join(RadarDataORM, SARSceneGeoORM.radar_data_id == RadarDataORM.id)
.where(*post_filters)
)
candidates = []
for pre_scene, pre_radar in pre_result.all():
for post_scene, post_radar in post_result.all():
if pre_scene.id == post_scene.id:
continue
ratio = 0.0
if pre_radar.coverage_polygon and post_radar.coverage_polygon:
ratio = _overlap_ratio(pre_radar.coverage_polygon, post_radar.coverage_polygon)
if ratio < req.overlap_threshold:
continue
try:
pre_date = datetime.strptime(pre_radar.imaging_date, "%Y%m%d")
post_date = datetime.strptime(post_radar.imaging_date, "%Y%m%d")
time_diff = abs((post_date - pre_date).days)
except Exception:
time_diff = None
candidates.append(
{
"pre": {
"id": pre_scene.id,
"imaging_date": pre_radar.imaging_date,
"satellite": pre_radar.satellite,
"geo_path": _scene_analysis_path(pre_scene),
"analysis_tif_path": _scene_analysis_path(pre_scene),
},
"post": {
"id": post_scene.id,
"imaging_date": post_radar.imaging_date,
"satellite": post_radar.satellite,
"geo_path": _scene_analysis_path(post_scene),
"analysis_tif_path": _scene_analysis_path(post_scene),
},
"overlap_ratio": round(ratio, 4),
"time_diff_days": time_diff,
}
)
candidates.sort(key=lambda item: item["overlap_ratio"], reverse=True)
used_pre: set[int] = set()
used_post: set[int] = set()
pairs = []
for candidate in candidates:
pre_id = candidate["pre"]["id"]
post_id = candidate["post"]["id"]
if pre_id in used_pre or post_id in used_post:
continue
used_pre.add(pre_id)
used_post.add(post_id)
pairs.append(candidate)
pairs.sort(key=lambda item: item["overlap_ratio"], reverse=True)
return {"pairs": pairs, "total": len(pairs)}
async def search_disaster_pairs(req: Any, db: AsyncSession) -> dict[str, Any]:
disaster_date = _parse_ymd(req.disaster_date, field="disaster_date")
pre_window_days = max(1, int(getattr(req, "pre_window_days", 30) or 30))
post_window_days = max(1, int(getattr(req, "post_window_days", 30) or 30))
min_aoi_coverage_ratio = max(0.0, min(1.0, float(getattr(req, "min_aoi_coverage_ratio", 0.2) or 0.0)))
min_pair_overlap_ratio = max(0.0, min(1.0, float(getattr(req, "min_pair_overlap_ratio", 0.3) or 0.0)))
max_pairs = max(1, min(200, int(getattr(req, "max_pairs", 50) or 50)))
aoi_wkt, aoi_geojson, aoi_meta = _resolve_aoi_wkt_from_request(req)
pre_start = disaster_date - timedelta(days=pre_window_days)
pre_end = disaster_date - timedelta(days=1)
post_start = disaster_date
post_end = disaster_date + timedelta(days=post_window_days)
pre_pool = await _query_disaster_scene_pool(
db=db,
req=req,
aoi_wkt=aoi_wkt,
start_ymd=_format_ymd(pre_start),
end_ymd=_format_ymd(pre_end),
min_aoi_coverage_ratio=min_aoi_coverage_ratio,
descending=True,
)
post_pool = await _query_disaster_scene_pool(
db=db,
req=req,
aoi_wkt=aoi_wkt,
start_ymd=_format_ymd(post_start),
end_ymd=_format_ymd(post_end),
min_aoi_coverage_ratio=min_aoi_coverage_ratio,
descending=False,
)
candidates: list[dict[str, Any]] = []
require_same_polarization = bool(getattr(req, "require_same_polarization", True))
require_same_imaging_mode = bool(getattr(req, "require_same_imaging_mode", False))
total_window = max(1, pre_window_days + post_window_days)
for pre in pre_pool:
pre_date = _parse_ymd(pre.get("imaging_date"), field="pre.imaging_date")
for post in post_pool:
if pre["id"] == post["id"]:
continue
if require_same_polarization and not _same_text(pre.get("polarization"), post.get("polarization")):
continue
if require_same_imaging_mode and not _same_text(pre.get("imaging_mode"), post.get("imaging_mode")):
continue
post_date = _parse_ymd(post.get("imaging_date"), field="post.imaging_date")
scene_overlap = _overlap_ratio(pre.get("coverage_polygon"), post.get("coverage_polygon"))
if scene_overlap < min_pair_overlap_ratio:
continue
pre_delta_days = max(0, (disaster_date - pre_date).days)
post_delta_days = max(0, (post_date - disaster_date).days)
time_score = max(0.0, 1.0 - ((pre_delta_days + post_delta_days) / total_window))
aoi_score = min(_to_float(pre.get("aoi_coverage_ratio")), _to_float(post.get("aoi_coverage_ratio")))
score = (scene_overlap * 0.45) + (aoi_score * 0.35) + (time_score * 0.20)
candidates.append(
{
"pre": pre,
"post": post,
"overlap_ratio": round(scene_overlap, 4),
"aoi_coverage_ratio": round(aoi_score, 4),
"time_score": round(time_score, 4),
"score": round(score, 4),
"pre_delta_days": pre_delta_days,
"post_delta_days": post_delta_days,
"time_diff_days": max(0, (post_date - pre_date).days),
"same_polarization": _same_text(pre.get("polarization"), post.get("polarization")),
"same_imaging_mode": _same_text(pre.get("imaging_mode"), post.get("imaging_mode")),
}
)
candidates.sort(
key=lambda item: (
item["score"],
item["overlap_ratio"],
item["aoi_coverage_ratio"],
-item["time_diff_days"],
),
reverse=True,
)
selected_pairs = candidates[:max_pairs]
warnings: list[str] = []
if not pre_pool:
warnings.append("No pre-disaster DONE scenes match the disaster AOI and time window")
if not post_pool:
warnings.append("No post-disaster DONE scenes match the disaster AOI and time window")
if pre_pool and post_pool and not selected_pairs:
warnings.append("Pre/post scene pools exist, but no pair meets the overlap/polarization constraints")
return {
"disaster": {
"name": getattr(req, "disaster_name", None),
"date": _format_ymd(disaster_date),
"pre_start": _format_ymd(pre_start),
"pre_end": _format_ymd(pre_end),
"post_start": _format_ymd(post_start),
"post_end": _format_ymd(post_end),
},
"aoi": {
**aoi_meta,
"geojson": aoi_geojson,
},
"pre_pool": pre_pool,
"post_pool": post_pool,
"candidate_pairs": selected_pairs,
"pairs": selected_pairs,
"total": len(selected_pairs),
"summary": {
"pre_pool_count": len(pre_pool),
"post_pool_count": len(post_pool),
"candidate_count": len(selected_pairs),
"min_aoi_coverage_ratio": min_aoi_coverage_ratio,
"min_pair_overlap_ratio": min_pair_overlap_ratio,
},
"warnings": warnings,
}
def _open_envi_rasterio(path: str):
import rasterio
normalized_path = path.replace("\\", "/")
try:
return rasterio.open(normalized_path)
except Exception:
pass
for ext in (".bin", ".img", ".tif", ".tiff"):
try:
return rasterio.open(normalized_path + ext)
except Exception:
pass
raise FileNotFoundError(f"Raster file cannot be opened: {path}")
def _raster_to_png_bytes(path: str, colormap: dict[int, tuple[int, int, int, int]]) -> tuple[bytes, list[float]]:
import numpy as np
from PIL import Image
with _open_envi_rasterio(path) as ds:
data = ds.read(1)
bounds = ds.bounds
geo_bounds = [bounds.bottom, bounds.left, bounds.top, bounds.right]
rgba = np.zeros((data.shape[0], data.shape[1], 4), dtype=np.uint8)
for value, color in colormap.items():
rgba[data == value] = color
image = Image.fromarray(rgba, "RGBA")
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue(), geo_bounds
def _geo_raster_to_png_bytes(path: str) -> tuple[bytes, list[float]]:
import numpy as np
from PIL import Image
with _open_envi_rasterio(path) as ds:
data = ds.read(1).astype("float32")
nodata = ds.nodata
bounds = ds.bounds
geo_bounds = [bounds.bottom, bounds.left, bounds.top, bounds.right]
if nodata is not None:
nodata_mask = (data == nodata) | ~np.isfinite(data)
else:
nodata_mask = ~np.isfinite(data)
valid = data[~nodata_mask]
if valid.size == 0:
normalized = np.zeros_like(data, dtype=np.uint8)
else:
p2, p98 = np.percentile(valid, 2), np.percentile(valid, 98)
clipped = np.clip(data, p2, p98)
normalized = ((clipped - p2) / max(p98 - p2, 1e-9) * 255).astype(np.uint8)
rgba = np.stack([normalized, normalized, normalized, np.full_like(normalized, 200)], axis=-1)
rgba[nodata_mask, 3] = 0
image = Image.fromarray(rgba, "RGBA")
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue(), geo_bounds
_FLOOD_COLORMAP = {
1: (24, 144, 255, 200),
2: (255, 77, 79, 220),
3: (250, 173, 20, 180),
4: (80, 80, 80, 80),
}
async def get_flood_detection_preview(detection_id: int, layer: str, db: AsyncSession):
normalized_layer = layer.strip().lower()
if normalized_layer == "classified":
detection = await db.get(FloodDetectionORM, detection_id)
if not detection or not detection.classified_path:
raise HTTPException(status_code=404, detail="Classified result not found")
path = detection.classified_path.replace("\\", "/")
png_bytes, geo_bounds = await _render_classified_preview(path)
return JSONResponse(
{
"image_b64": base64.b64encode(png_bytes).decode(),
"bounds": geo_bounds,
"legend": {
"stable_water": "#1890ff",
"flood": "#ff4d4f",
"high_backscatter": "#faad14",
"non_water": "#505050",
},
}
)
if normalized_layer in ("pre", "post"):
return await _get_scene_preview_for_detection(detection_id, normalized_layer, db)
raise HTTPException(status_code=404, detail=f"Unsupported preview layer: {layer}")
async def _render_classified_preview(path: str) -> tuple[bytes, list[float]]:
if not os.path.isfile(path):
raise HTTPException(status_code=404, detail="Requested file does not exist")
try:
return await asyncio.to_thread(_raster_to_png_bytes, path, _FLOOD_COLORMAP)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Render failed: {exc}") from exc
async def _get_scene_preview_for_detection(detection_id: int, layer: str, db: AsyncSession):
detection = await db.get(FloodDetectionORM, detection_id)
if not detection:
raise HTTPException(status_code=404, detail="Flood detection not found")
scene_id = detection.pre_scene_id if layer == "pre" else detection.post_scene_id
scene = await db.get(SARSceneGeoORM, scene_id)
scene_path = _scene_analysis_path(scene)
if not scene_path:
raise HTTPException(status_code=404, detail="Scene analysis-ready GeoTIFF not found")
path = scene_path.replace("\\", "/")
if not os.path.isfile(path):
raise HTTPException(status_code=404, detail="Requested file does not exist")
try:
png_bytes, geo_bounds = await asyncio.to_thread(_geo_raster_to_png_bytes, path)
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Render failed: {exc}") from exc
return JSONResponse({"image_b64": base64.b64encode(png_bytes).decode(), "bounds": geo_bounds})
async def get_water_extraction_preview(extraction_id: int, db: AsyncSession) -> dict[str, Any]:
detection = await db.get(WaterExtractionORM, extraction_id)
if not detection:
detection = await db.get(WaterDetectionORM, extraction_id)
if not detection:
raise HTTPException(status_code=404, detail=f"Water extraction id={extraction_id} not found")
if not detection.output_path or not os.path.isfile(detection.output_path):
raise HTTPException(status_code=404, detail="Output file does not exist")
import numpy as np
import rasterio
from PIL import Image
with rasterio.open(detection.output_path) as src:
data = src.read(1)
transform = src.transform
height, width = data.shape
min_lon = transform.c
max_lon = transform.c + width * transform.a
max_lat = transform.f
min_lat = transform.f + height * transform.e
rgba = np.zeros((data.shape[0], data.shape[1], 4), dtype=np.uint8)
rgba[data > 0] = [24, 144, 255, 160]
image = Image.fromarray(rgba, "RGBA")
max_dim = 1024
if max(width, height) > max_dim:
ratio = max_dim / max(width, height)
image = image.resize((int(width * ratio), int(height * ratio)), Image.NEAREST)
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return {
"png_base64": base64.b64encode(buffer.getvalue()).decode(),
"bounds": {
"min_lon": min_lon,
"min_lat": min_lat,
"max_lon": max_lon,
"max_lat": max_lat,
},
}
@@ -0,0 +1,327 @@
"""Pure GeoTIFF flood detection for the flood-analysis module.
This service deliberately does not depend on ENVI/SARscape. Satellite-specific
preprocessors are responsible only for producing analysis-ready GeoTIFFs; the
flood classification below operates on those GeoTIFFs with Python/rasterio.
"""
from __future__ import annotations
import json
import math
import os
from pathlib import Path
from typing import Any
import numpy as np
def _valid_mask(data: np.ndarray, nodata: float | int | None) -> np.ndarray:
valid = np.isfinite(data)
if nodata is not None and np.isfinite(float(nodata)):
valid &= data != float(nodata)
return valid
def _sample_valid(values: np.ndarray, max_samples: int = 1_000_000) -> np.ndarray:
flat = values[np.isfinite(values)]
if flat.size <= max_samples:
return flat
step = max(1, int(math.ceil(flat.size / max_samples)))
return flat[::step]
def _otsu_threshold(values: np.ndarray) -> float:
sample = _sample_valid(values)
if sample.size < 100:
raise ValueError("Too few valid pixels for thresholding")
manual_threshold = _manual_otsu_threshold(sample)
try:
from skimage.filters import threshold_otsu
skimage_threshold = float(threshold_otsu(sample))
if np.isfinite(skimage_threshold):
p05, p95 = np.nanpercentile(sample, [5, 95])
if p05 < skimage_threshold < p95:
return skimage_threshold
return manual_threshold
except Exception:
return manual_threshold
def _manual_otsu_threshold(values: np.ndarray) -> float:
sample = _sample_valid(values)
if sample.size < 100:
raise ValueError("Too few valid pixels for thresholding")
vmin = float(np.nanmin(sample))
vmax = float(np.nanmax(sample))
if not np.isfinite(vmin) or not np.isfinite(vmax):
raise ValueError("Input pixels are not finite")
if math.isclose(vmin, vmax):
return vmin
hist, edges = np.histogram(sample, bins=256, range=(vmin, vmax))
hist = hist.astype("float64")
centers = (edges[:-1] + edges[1:]) / 2.0
total = hist.sum()
if total <= 0:
return float(np.nanpercentile(sample, 10))
weight_background = np.cumsum(hist)
weight_foreground = total - weight_background
mean_background = np.cumsum(hist * centers) / np.maximum(weight_background, 1e-12)
mean_foreground = (
np.cumsum((hist * centers)[::-1]) / np.maximum(np.cumsum(hist[::-1]), 1e-12)
)[::-1]
variance = weight_background[:-1] * weight_foreground[:-1] * (
mean_background[:-1] - mean_foreground[1:]
) ** 2
if variance.size == 0 or not np.isfinite(variance).any():
return float(np.nanpercentile(sample, 10))
idx = int(np.nanargmax(variance))
return float(edges[idx + 1])
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 _clean_mask(mask: np.ndarray, min_pixels: int) -> np.ndarray:
try:
from scipy.ndimage import binary_closing, binary_opening, generate_binary_structure, label
except Exception:
return mask
structure = generate_binary_structure(2, 2)
cleaned = binary_closing(mask, structure=structure, iterations=1)
cleaned = binary_opening(cleaned, structure=structure, iterations=1)
if min_pixels <= 1:
return cleaned
labels, count = label(cleaned)
if count <= 0:
return cleaned
component_sizes = np.bincount(labels.ravel())
keep = component_sizes >= int(min_pixels)
keep[0] = False
return keep[labels]
def _read_pre_on_post_grid(pre_path: str, post_profile: dict[str, Any]) -> tuple[np.ndarray, dict[str, Any]]:
import rasterio
from rasterio.enums import Resampling
from rasterio.warp import reproject
with rasterio.open(pre_path) as pre_ds:
pre_data = pre_ds.read(1).astype("float32")
pre_nodata = pre_ds.nodata
same_grid = (
pre_ds.width == int(post_profile["width"])
and pre_ds.height == int(post_profile["height"])
and pre_ds.transform == post_profile["transform"]
and str(pre_ds.crs or "") == str(post_profile["crs"] or "")
)
metadata = {
"path": pre_path,
"crs": pre_ds.crs.to_string() if pre_ds.crs else None,
"width": pre_ds.width,
"height": pre_ds.height,
"nodata": pre_nodata,
"reprojected_to_post_grid": not same_grid,
}
if same_grid:
data = pre_data.astype("float32")
if pre_nodata is not None and np.isfinite(float(pre_nodata)):
data[data == float(pre_nodata)] = np.nan
return data, metadata
if not pre_ds.crs or not post_profile["crs"]:
raise ValueError("Pre/post GeoTIFF CRS is required when grids differ")
destination = np.full(
(int(post_profile["height"]), int(post_profile["width"])),
np.nan,
dtype="float32",
)
reproject(
source=pre_data,
destination=destination,
src_transform=pre_ds.transform,
src_crs=pre_ds.crs,
src_nodata=pre_nodata,
dst_transform=post_profile["transform"],
dst_crs=post_profile["crs"],
dst_nodata=np.nan,
resampling=Resampling.bilinear,
)
return destination, metadata
def run_geotiff_flood_detection(
*,
pre_tif_path: str,
post_tif_path: str,
output_dir: str,
job_id: str | None = None,
refine: bool = False,
) -> dict[str, Any]:
"""Classify stable water and new flood extent from two analysis-ready GeoTIFFs."""
import rasterio
pre_path = Path(os.path.normpath(str(pre_tif_path or "").strip()))
post_path = Path(os.path.normpath(str(post_tif_path or "").strip()))
out_dir = Path(os.path.normpath(str(output_dir or "").strip()))
if not pre_path.is_file():
return {"ok": False, "error": f"Pre-event analysis GeoTIFF not found: {pre_path}"}
if not post_path.is_file():
return {"ok": False, "error": f"Post-event analysis GeoTIFF not found: {post_path}"}
out_dir.mkdir(parents=True, exist_ok=True)
with rasterio.open(post_path) as post_ds:
post_data = post_ds.read(1).astype("float32")
post_nodata = post_ds.nodata
post_profile = post_ds.profile.copy()
post_grid = {
"height": post_ds.height,
"width": post_ds.width,
"transform": post_ds.transform,
"crs": post_ds.crs,
}
post_metadata = {
"path": str(post_path),
"crs": post_ds.crs.to_string() if post_ds.crs else None,
"width": post_ds.width,
"height": post_ds.height,
"nodata": post_nodata,
}
pixel_area_km2 = _pixel_area_km2(post_ds.transform, post_ds.crs, post_ds.bounds)
pre_data, pre_metadata = _read_pre_on_post_grid(str(pre_path), post_grid)
valid_pre = _valid_mask(pre_data, None)
valid_post = _valid_mask(post_data, post_nodata)
valid = valid_pre & valid_post
if int(np.count_nonzero(valid)) < 100:
return {"ok": False, "error": "Too few overlapping valid pixels between pre/post GeoTIFFs"}
pre_valid_values = pre_data[valid]
post_valid_values = post_data[valid]
pre_threshold = _otsu_threshold(pre_valid_values)
post_threshold = _otsu_threshold(post_valid_values)
pre_water = (pre_data <= pre_threshold) & valid
post_water = (post_data <= post_threshold) & valid
stable_water = pre_water & post_water
flood = post_water & ~pre_water
if refine:
min_pixels = max(4, int(round(3_000.0 / max(pixel_area_km2 * 1_000_000.0, 1.0))))
stable_water = _clean_mask(stable_water, min_pixels=min_pixels)
flood = _clean_mask(flood, min_pixels=min_pixels)
high_threshold = float(np.nanpercentile(post_valid_values, 98))
high_backscatter = (post_data >= high_threshold) & valid & ~(stable_water | flood)
classified = np.zeros(post_data.shape, dtype="uint8")
classified[valid] = 4
classified[high_backscatter] = 3
classified[stable_water] = 1
classified[flood] = 2
classified_path = out_dir / "classified.tif"
flood_mask_path = out_dir / "flood_mask.tif"
stable_mask_path = out_dir / "stable_water_mask.tif"
classified_profile = post_profile.copy()
classified_profile.update(
driver="GTiff",
dtype="uint8",
count=1,
nodata=0,
compress="deflate",
)
with rasterio.open(classified_path, "w", **classified_profile) as dst:
dst.write(classified, 1)
try:
dst.write_colormap(
1,
{
0: (0, 0, 0, 0),
1: (24, 144, 255, 255),
2: (255, 77, 79, 255),
3: (250, 173, 20, 255),
4: (80, 80, 80, 255),
},
)
except Exception:
pass
mask_profile = classified_profile.copy()
mask_profile.update(nodata=0)
with rasterio.open(flood_mask_path, "w", **mask_profile) as dst:
dst.write(np.where(flood, 255, 0).astype("uint8"), 1)
with rasterio.open(stable_mask_path, "w", **mask_profile) as dst:
dst.write(np.where(stable_water, 255, 0).astype("uint8"), 1)
flood_pixels = int(np.count_nonzero(flood))
stable_pixels = int(np.count_nonzero(stable_water))
high_pixels = int(np.count_nonzero(high_backscatter))
non_water_pixels = int(np.count_nonzero(classified == 4))
metadata = {
"schema": "flood_detection_geotiff.v1",
"job_id": job_id,
"processor": "python_geotiff_otsu_change",
"refine": bool(refine),
"pre": pre_metadata,
"post": post_metadata,
"thresholds": {
"pre_water_threshold": pre_threshold,
"post_water_threshold": post_threshold,
"post_high_backscatter_threshold": high_threshold,
},
"pixel_area_km2": pixel_area_km2,
"class_values": {
"0": "nodata",
"1": "stable_water",
"2": "flood",
"3": "high_backscatter",
"4": "non_water",
},
"counts": {
"valid_pixels": int(np.count_nonzero(valid)),
"stable_water_pixels": stable_pixels,
"flood_pixels": flood_pixels,
"high_backscatter_pixels": high_pixels,
"non_water_pixels": non_water_pixels,
},
"outputs": {
"classified_path": str(classified_path),
"flood_mask_path": str(flood_mask_path),
"stable_water_mask_path": str(stable_mask_path),
},
}
metadata_path = out_dir / "metadata.json"
metadata_path.write_text(json.dumps(metadata, ensure_ascii=False, indent=2, default=str), encoding="utf-8")
return {
"ok": True,
"classified_path": str(classified_path),
"flood_mask_path": str(flood_mask_path),
"stable_water_mask_path": str(stable_mask_path),
"metadata_path": str(metadata_path),
"flood_area_km2": round(flood_pixels * pixel_area_km2, 4),
"stable_water_area_km2": round(stable_pixels * pixel_area_km2, 4),
"flood_pixel_count": flood_pixels,
"stable_water_pixel_count": stable_pixels,
"processor": "python_geotiff_otsu_change",
"log": [
"pre/post analysis-ready GeoTIFFs loaded",
"pre scene reprojected to post-event grid",
f"thresholds pre={pre_threshold:.4f}, post={post_threshold:.4f}",
f"flood_pixels={flood_pixels}, stable_water_pixels={stable_pixels}",
],
}
@@ -0,0 +1,389 @@
"""Flood overlay and impact analysis service."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any
from fastapi import HTTPException
from geoalchemy2.functions import ST_Intersects
from geoalchemy2.shape import from_shape
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from shapely.geometry import mapping, shape
from shapely.ops import unary_union
from ..config import settings
from ..models import FloodDetectionORM, FloodOverlayORM, HazardPointORM, ResultProductORM
def _to_float(value: Any) -> float | None:
try:
if value is None:
return None
return float(value)
except Exception:
return None
def _hazard_point_to_dict(point: HazardPointORM, *, distance_m: float | None = None) -> dict[str, Any]:
return {
"id": point.id,
"name": point.hazard_name,
"type": point.hazard_type,
"city": point.city,
"county": point.county,
"township": point.township,
"longitude": _to_float(point.longitude),
"latitude": _to_float(point.latitude),
"distance_m": 0 if distance_m is None else round(float(distance_m), 2),
}
def _dinsar_product_to_dict(product: ResultProductORM) -> dict[str, Any]:
summary = product.summary_json if isinstance(product.summary_json, dict) else {}
deformation = (
summary.get("deformation_mm")
or summary.get("max_deformation_mm")
or summary.get("mean_deformation_mm")
or summary.get("deformation")
)
return {
"id": product.id,
"product_id": product.product_id,
"display_name": product.display_name,
"engine": product.engine_code,
"status": product.status,
"deformation_mm": deformation,
"ai_score": product.ai_score,
"manifest_path": product.manifest_path,
"preview_path": product.preview_path,
}
def _open_raster(path: str):
import rasterio
normalized_path = path.replace("\\", "/")
try:
return rasterio.open(normalized_path)
except Exception:
pass
for ext in (".bin", ".img", ".tif", ".tiff"):
candidate = normalized_path + ext
try:
return rasterio.open(candidate)
except Exception:
pass
raise FileNotFoundError(f"Raster file cannot be opened: {path}")
def _classified_flood_to_geojson(path: str) -> tuple[dict[str, Any], float | None, list[str]]:
import rasterio.features
from pyproj import CRS, Transformer
from shapely.geometry import shape as shape_geojson
from shapely.ops import transform
warnings: list[str] = []
with _open_raster(path) as src:
data = src.read(1)
mask = data == 2
if not mask.any():
return {"type": "FeatureCollection", "features": []}, 0.0, warnings
polygons = []
for geom, value in rasterio.features.shapes(data, mask=mask, transform=src.transform):
if int(value) != 2:
continue
polygon = shape_geojson(geom)
if not polygon.is_empty and polygon.is_valid:
polygons.append(polygon)
if not polygons:
return {"type": "FeatureCollection", "features": []}, 0.0, warnings
flood_geom = unary_union(polygons)
source_crs = src.crs
area_km2 = None
output_geom = flood_geom
if source_crs:
try:
crs = CRS.from_user_input(source_crs)
if not crs.is_geographic:
area_km2 = float(flood_geom.area) / 1_000_000.0
transformer = Transformer.from_crs(crs, CRS.from_epsg(4326), always_xy=True)
output_geom = transform(transformer.transform, flood_geom)
else:
centroid = flood_geom.centroid
zone = int((centroid.x + 180) // 6) + 1
epsg = 32600 + zone if centroid.y >= 0 else 32700 + zone
transformer = Transformer.from_crs(crs, CRS.from_epsg(epsg), always_xy=True)
projected = transform(transformer.transform, flood_geom)
area_km2 = float(projected.area) / 1_000_000.0
except Exception as exc:
warnings.append(f"area calculation failed: {exc}")
else:
warnings.append("classified raster has no CRS; geometry is stored in source coordinates")
feature_collection = {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": {"class": 2, "name": "flood"},
"geometry": mapping(output_geom),
}
],
}
return feature_collection, area_km2, warnings
def _write_geojson(detection_id: int, feature_collection: dict[str, Any]) -> str:
out_dir = Path(settings.WATER_RESULTS_DIR or Path(settings.BACKEND_DIR) / "water_results") / "flood_overlays"
out_dir.mkdir(parents=True, exist_ok=True)
target = out_dir / f"flood_detection_{detection_id}_overlay.geojson"
target.write_text(json.dumps(feature_collection, ensure_ascii=False, indent=2), encoding="utf-8")
return str(target)
def _read_geojson(path: str | None) -> dict[str, Any] | None:
if not path or not os.path.isfile(path):
return None
try:
with open(path, "r", encoding="utf-8") as stream:
payload = json.load(stream)
return payload if isinstance(payload, dict) else None
except Exception:
return None
def _geometry_area_km2(geom) -> float:
if geom.is_empty:
return 0.0
try:
from pyproj import CRS, Transformer
from shapely.ops import transform
centroid = geom.centroid
zone = int((centroid.x + 180) // 6) + 1
epsg = 32600 + zone if centroid.y >= 0 else 32700 + zone
transformer = Transformer.from_crs(CRS.from_epsg(4326), CRS.from_epsg(epsg), always_xy=True)
projected = transform(transformer.transform, geom)
return float(projected.area) / 1_000_000.0
except Exception:
return 0.0
def _calculate_affected_aois(flood_geom, warnings: list[str], *, limit: int = 50) -> list[dict[str, Any]]:
try:
from ..routers import dependencies as deps
deps._load_region_index()
deps._load_region_geometry_index()
region_by_id = deps._REGION_BY_ID_CACHE or {}
geometry_by_id = deps._REGION_GEOMETRY_BY_ID_CACHE or {}
except Exception as exc:
warnings.append(f"AOI overlay unavailable: {exc}")
return []
affected: list[dict[str, Any]] = []
for tree_id, features in geometry_by_id.items():
node = region_by_id.get(tree_id) or {}
level = node.get("level")
if level in {"country", "province"}:
continue
try:
geometries = [shape(feature["geometry"]) for feature in features if feature.get("geometry")]
if not geometries:
continue
region_geom = unary_union(geometries)
if region_geom.is_empty or not flood_geom.intersects(region_geom):
continue
intersection = flood_geom.intersection(region_geom)
area_km2 = _geometry_area_km2(intersection)
if area_km2 <= 0.0001:
continue
affected.append(
{
"tree_id": tree_id,
"name": node.get("name") or tree_id,
"level": level,
"flood_area_km2": round(area_km2, 4),
}
)
except Exception:
continue
affected.sort(key=lambda item: item["flood_area_km2"], reverse=True)
return affected[:limit]
def _attach_overlay_payload(overlay: FloodOverlayORM) -> dict[str, Any]:
payload = dict(overlay.summary_json) if isinstance(overlay.summary_json, dict) else {}
payload["overlay_id"] = overlay.id
payload["detection_id"] = overlay.detection_id
payload["flood_vector_path"] = overlay.flood_vector_path
payload["flood_vector_geojson"] = _read_geojson(overlay.flood_vector_path)
return payload
async def run_overlay(detection_id: int, db: AsyncSession, *, near_threshold_m: float = 500.0) -> dict[str, Any]:
detection = await db.get(FloodDetectionORM, detection_id)
if not detection:
raise HTTPException(status_code=404, detail=f"Flood detection id={detection_id} not found")
if not detection.classified_path:
raise HTTPException(status_code=400, detail="Flood detection has no classified raster")
path = detection.classified_path.replace("\\", "/")
if not os.path.isfile(path):
raise HTTPException(status_code=404, detail="Classified raster file does not exist")
feature_collection, flood_area_km2, warnings = _classified_flood_to_geojson(path)
flood_vector_path = _write_geojson(detection_id, feature_collection)
impact = await _query_impact_from_geojson(
detection_id=detection_id,
feature_collection=feature_collection,
db=db,
near_threshold_m=near_threshold_m,
warnings=warnings,
)
if flood_area_km2 is not None:
impact["flood_area_km2"] = round(flood_area_km2, 4)
impact["flood_vector_path"] = flood_vector_path
overlay = FloodOverlayORM(
detection_id=detection_id,
flood_vector_path=flood_vector_path,
hazard_points_hit=len(impact["hazard_points"]["inside_flood"]),
hazard_points_near=len(impact["hazard_points"]["near_flood"]),
hazard_points_total=impact["hazard_points"]["total_in_scene"],
dinsar_products_intersecting=len(impact["dinsar_products"]),
affected_area_km2=impact.get("flood_area_km2"),
summary_json=impact,
)
db.add(overlay)
await db.flush()
impact["overlay_id"] = overlay.id
overlay.summary_json = impact
if flood_area_km2 is not None:
detection.flood_area_km2 = round(flood_area_km2, 4)
await db.commit()
await db.refresh(overlay)
return {
"id": overlay.id,
"detection_id": detection_id,
"flood_vector_path": overlay.flood_vector_path,
"flood_vector_geojson": feature_collection,
"summary": _attach_overlay_payload(overlay),
}
async def get_overlay_result(detection_id: int, db: AsyncSession) -> dict[str, Any]:
overlay = (
await db.execute(
select(FloodOverlayORM)
.where(FloodOverlayORM.detection_id == detection_id)
.order_by(FloodOverlayORM.id.desc())
)
).scalars().first()
if overlay and isinstance(overlay.summary_json, dict):
return _attach_overlay_payload(overlay)
detection = await db.get(FloodDetectionORM, detection_id)
if not detection:
raise HTTPException(status_code=404, detail=f"Flood detection id={detection_id} not found")
return {
"detection_id": detection_id,
"flood_area_km2": detection.flood_area_km2,
"hazard_points": {"inside_flood": [], "near_flood": [], "total_in_scene": 0},
"dinsar_products": [],
"affected_aois": [],
"flood_vector_path": None,
"flood_vector_geojson": None,
"warnings": ["overlay has not been run"],
}
async def _query_impact_from_geojson(
*,
detection_id: int,
feature_collection: dict[str, Any],
db: AsyncSession,
near_threshold_m: float,
warnings: list[str],
) -> dict[str, Any]:
features = feature_collection.get("features") or []
if not features:
return {
"detection_id": detection_id,
"flood_area_km2": 0.0,
"hazard_points": {"inside_flood": [], "near_flood": [], "total_in_scene": 0},
"dinsar_products": [],
"affected_aois": [],
"warnings": warnings,
}
flood_geom = unary_union([shape(feature["geometry"]) for feature in features if feature.get("geometry")])
if flood_geom.is_empty:
warnings.append("flood geometry is empty")
flood_wkt = flood_geom.wkt
area_geom = func.ST_GeomFromText(flood_wkt, 4326)
area_geog = func.Geography(area_geom)
inside_points: list[dict[str, Any]] = []
near_points: list[dict[str, Any]] = []
dinsar_products: list[dict[str, Any]] = []
affected_aois = _calculate_affected_aois(flood_geom, warnings)
try:
inside_rows = (
await db.execute(
select(HazardPointORM).where(ST_Intersects(HazardPointORM.geom, area_geom))
)
).scalars().all()
inside_ids = {point.id for point in inside_rows}
inside_points = [_hazard_point_to_dict(point, distance_m=0) for point in inside_rows]
near_rows = (
await db.execute(
select(
HazardPointORM,
func.ST_Distance(func.Geography(HazardPointORM.geom), area_geog).label("distance_m"),
).where(func.ST_DWithin(func.Geography(HazardPointORM.geom), area_geog, near_threshold_m))
)
).all()
for point, distance_m in near_rows:
if point.id in inside_ids:
continue
near_points.append(_hazard_point_to_dict(point, distance_m=distance_m))
except Exception as exc:
warnings.append(f"hazard point overlay unavailable: {exc}")
try:
products = (
await db.execute(
select(ResultProductORM).where(
ResultProductORM.catalog_name == "dinsar",
ResultProductORM.status == "READY",
ST_Intersects(ResultProductORM.geom, area_geom),
)
)
).scalars().all()
dinsar_products = [_dinsar_product_to_dict(product) for product in products]
except Exception as exc:
warnings.append(f"dinsar product overlay unavailable: {exc}")
return {
"detection_id": detection_id,
"flood_area_km2": None,
"hazard_points": {
"inside_flood": inside_points,
"near_flood": near_points,
"total_in_scene": len(inside_points) + len(near_points),
},
"dinsar_products": dinsar_products,
"affected_aois": affected_aois,
"warnings": warnings,
}
@@ -0,0 +1,199 @@
"""Flood product listing and manifest helpers."""
from __future__ import annotations
import json
import os
from datetime import datetime
from typing import Any
from fastapi import HTTPException
from sqlalchemy import select, func
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload
from ..models import FloodDetectionORM, FloodOverlayORM, FloodProductORM, SARSceneGeoORM
def _iso(value: Any) -> str | None:
return value.isoformat() if value else None
def _product_to_dict(product: FloodProductORM) -> dict[str, Any]:
summary = product.summary_json if isinstance(product.summary_json, dict) else {}
detection = product.detection
overlay = product.overlay
return {
"id": product.id,
"product_id": product.product_id,
"detection_id": product.detection_id,
"overlay_id": product.overlay_id,
"display_name": product.display_name,
"status": product.status,
"publish_dir": product.publish_dir,
"manifest_path": product.manifest_path,
"summary": summary,
"created_at": _iso(product.created_at),
"flood_area_km2": getattr(detection, "flood_area_km2", None),
"affected_area_km2": getattr(overlay, "affected_area_km2", None),
}
async def list_flood_products(
*,
db: AsyncSession,
limit: int = 20,
offset: int = 0,
status: str | None = None,
) -> dict[str, Any]:
count_query = select(func.count()).select_from(FloodProductORM)
query = (
select(FloodProductORM)
.options(
selectinload(FloodProductORM.detection),
selectinload(FloodProductORM.overlay),
)
.order_by(FloodProductORM.id.desc())
.limit(limit)
.offset(offset)
)
if status:
count_query = count_query.where(FloodProductORM.status == status)
query = query.where(FloodProductORM.status == status)
total = (await db.execute(count_query)).scalar_one()
rows = (await db.execute(query)).scalars().all()
return {"items": [_product_to_dict(row) for row in rows], "total": total}
async def get_flood_product(product_id_or_pk: str, db: AsyncSession) -> dict[str, Any]:
product = await _get_product(product_id_or_pk, db)
return _product_to_dict(product)
async def get_flood_product_manifest(product_id_or_pk: str, db: AsyncSession) -> dict[str, Any]:
product = await _get_product(product_id_or_pk, db)
if product.manifest_path and os.path.isfile(product.manifest_path):
try:
with open(product.manifest_path, "r", encoding="utf-8") as stream:
payload = json.load(stream)
if isinstance(payload, dict):
return payload
except Exception as exc:
raise HTTPException(status_code=500, detail=f"Failed to read manifest: {exc}") from exc
return _build_manifest_from_db(product)
async def create_flood_product_for_detection(detection_id: int, db: AsyncSession) -> dict[str, Any]:
detection = await db.get(FloodDetectionORM, detection_id)
if not detection:
raise HTTPException(status_code=404, detail=f"Flood detection id={detection_id} not found")
if detection.status != "DONE":
raise HTTPException(status_code=400, detail=f"Flood detection is not DONE: {detection.status}")
existing = (
await db.execute(
select(FloodProductORM)
.options(
selectinload(FloodProductORM.detection),
selectinload(FloodProductORM.overlay),
)
.where(FloodProductORM.detection_id == detection_id)
.order_by(FloodProductORM.id.desc())
)
).scalars().first()
if existing:
return _product_to_dict(existing)
overlay = (
await db.execute(
select(FloodOverlayORM)
.where(FloodOverlayORM.detection_id == detection_id)
.order_by(FloodOverlayORM.id.desc())
)
).scalars().first()
product = FloodProductORM(
product_id=f"FLOOD-{detection_id:06d}",
detection_id=detection_id,
overlay_id=overlay.id if overlay else None,
display_name=f"Flood detection #{detection_id}",
status="READY",
publish_dir=detection.output_dir,
manifest_path=None,
summary_json={
"created_from": "flood_detection",
"flood_area_km2": detection.flood_area_km2,
"stable_water_area_km2": detection.stable_water_area_km2,
"classified_path": detection.classified_path,
"created_at": datetime.utcnow().isoformat(),
},
)
db.add(product)
await db.commit()
return await get_flood_product(str(product.id), db)
async def _get_product(product_id_or_pk: str, db: AsyncSession) -> FloodProductORM:
value = str(product_id_or_pk).strip()
query = select(FloodProductORM).options(
selectinload(FloodProductORM.detection).selectinload(FloodDetectionORM.pre_scene).selectinload(SARSceneGeoORM.radar_data),
selectinload(FloodProductORM.detection).selectinload(FloodDetectionORM.post_scene).selectinload(SARSceneGeoORM.radar_data),
selectinload(FloodProductORM.overlay),
)
if value.isdigit():
query = query.where(FloodProductORM.id == int(value))
else:
query = query.where(FloodProductORM.product_id == value)
product = (await db.execute(query)).scalars().first()
if not product:
raise HTTPException(status_code=404, detail=f"Flood product {product_id_or_pk} not found")
return product
def _build_manifest_from_db(product: FloodProductORM) -> dict[str, Any]:
detection = product.detection
overlay = product.overlay
pre_scene = detection.pre_scene if detection else None
post_scene = detection.post_scene if detection else None
pre_radar = pre_scene.radar_data if pre_scene else None
post_radar = post_scene.radar_data if post_scene else None
return {
"schema": "flood_product_manifest.v1",
"product": _product_to_dict(product),
"detection": {
"id": detection.id if detection else None,
"status": detection.status if detection else None,
"classified_path": detection.classified_path if detection else None,
"flood_area_km2": detection.flood_area_km2 if detection else None,
"stable_water_area_km2": detection.stable_water_area_km2 if detection else None,
"pre_scene": {
"id": pre_scene.id if pre_scene else None,
"radar_data_id": pre_scene.radar_data_id if pre_scene else None,
"satellite": pre_radar.satellite if pre_radar else None,
"imaging_date": pre_radar.imaging_date if pre_radar else None,
"geo_path": pre_scene.geo_path if pre_scene else None,
"analysis_tif_path": pre_scene.analysis_tif_path if pre_scene else None,
"analysis_engine": pre_scene.analysis_engine if pre_scene else None,
"analysis_profile": pre_scene.analysis_profile if pre_scene else None,
},
"post_scene": {
"id": post_scene.id if post_scene else None,
"radar_data_id": post_scene.radar_data_id if post_scene else None,
"satellite": post_radar.satellite if post_radar else None,
"imaging_date": post_radar.imaging_date if post_radar else None,
"geo_path": post_scene.geo_path if post_scene else None,
"analysis_tif_path": post_scene.analysis_tif_path if post_scene else None,
"analysis_engine": post_scene.analysis_engine if post_scene else None,
"analysis_profile": post_scene.analysis_profile if post_scene else None,
},
},
"overlay": {
"id": overlay.id if overlay else None,
"flood_vector_path": overlay.flood_vector_path if overlay else None,
"affected_area_km2": overlay.affected_area_km2 if overlay else None,
"hazard_points_hit": overlay.hazard_points_hit if overlay else 0,
"hazard_points_near": overlay.hazard_points_near if overlay else 0,
"dinsar_products_intersecting": overlay.dinsar_products_intersecting if overlay else 0,
"summary": overlay.summary_json if overlay else None,
},
}
+369
View File
@@ -0,0 +1,369 @@
from __future__ import annotations
import logging
import os
import shutil
import stat
import tarfile
import zipfile
from datetime import datetime
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple
from ..config import settings, split_env_paths
logger = logging.getLogger(__name__)
LogCallback = Callable[[str, str], None]
ProgressCallback = Callable[[int, str], None]
DEFAULT_GF3_ARCHIVE_EXTS = (".zip", ".tar", ".tar.gz", ".tgz")
def _parse_float(value: Any, default: float) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _normalize_paths(paths: Optional[Iterable[str]]) -> List[str]:
ordered: List[str] = []
for raw_path in paths or []:
text = str(raw_path or "").strip().strip('"').strip("'")
if not text:
continue
normalized = os.path.normpath(os.path.abspath(text))
if normalized not in ordered:
ordered.append(normalized)
return ordered
def _normalize_extensions(extensions: Optional[Iterable[str]]) -> List[str]:
ordered: List[str] = []
for raw_ext in extensions or DEFAULT_GF3_ARCHIVE_EXTS:
ext = str(raw_ext or "").strip().lower()
if not ext:
continue
if not ext.startswith("."):
ext = f".{ext}"
if ext not in ordered:
ordered.append(ext)
return sorted(ordered or list(DEFAULT_GF3_ARCHIVE_EXTS), key=len, reverse=True)
def _strip_archive_extension(file_name: str, extensions: Iterable[str]) -> str:
lower_name = file_name.lower()
for ext in _normalize_extensions(extensions):
if lower_name.endswith(ext):
return file_name[: -len(ext)]
return os.path.splitext(file_name)[0]
def _resolve_target_root(archive_path: str, source_dirs: List[str], target_dirs: List[str]) -> str:
if not target_dirs:
raise ValueError("GF3_SOURCE_DIRS is not configured.")
if len(target_dirs) == 1:
return target_dirs[0]
if source_dirs and len(source_dirs) == len(target_dirs):
archive_norm = os.path.normcase(os.path.abspath(archive_path))
matches: List[Tuple[int, int]] = []
for index, source_dir in enumerate(source_dirs):
source_norm = os.path.normcase(os.path.abspath(source_dir))
if archive_norm == source_norm or archive_norm.startswith(source_norm + os.sep):
matches.append((len(source_norm), index))
if matches:
_prefix_len, best_index = max(matches)
return target_dirs[best_index]
return target_dirs[0]
def _validate_relative_member_name(member_name: str, archive_path: str) -> str:
name = str(member_name or "").replace("\\", "/")
if not name or name in {".", "./"}:
return ""
if name.startswith("/") or os.path.isabs(name) or os.path.splitdrive(name)[0]:
raise ValueError(f"Unsafe archive member path in {archive_path}: {member_name}")
parts = [part for part in name.split("/") if part not in ("", ".")]
if any(part == ".." for part in parts):
raise ValueError(f"Unsafe archive member path in {archive_path}: {member_name}")
if not parts:
return ""
return os.path.join(*parts)
def _safe_destination(root_dir: str, relative_name: str) -> str:
root_abs = os.path.abspath(root_dir)
destination = os.path.abspath(os.path.join(root_abs, relative_name))
if destination != root_abs and not destination.startswith(root_abs + os.sep):
raise ValueError(f"Unsafe extraction destination: {relative_name}")
return destination
def _validate_tar_members(members: Iterable[tarfile.TarInfo], archive_path: str) -> None:
for member in members:
_validate_relative_member_name(member.name, archive_path)
if member.issym() or member.islnk():
raise ValueError(f"Unsupported link entry in {archive_path}: {member.name}")
if not (member.isdir() or member.isfile()):
raise ValueError(f"Unsupported special entry in {archive_path}: {member.name}")
def _validate_zip_members(infos: Iterable[zipfile.ZipInfo], archive_path: str) -> None:
for info in infos:
_validate_relative_member_name(info.filename, archive_path)
mode = (info.external_attr >> 16) & 0o170000
if stat.S_ISLNK(mode):
raise ValueError(f"Unsupported symlink entry in {archive_path}: {info.filename}")
def _estimate_archive_size(archive_path: str) -> int:
if zipfile.is_zipfile(archive_path):
with zipfile.ZipFile(archive_path, "r") as zip_obj:
infos = zip_obj.infolist()
_validate_zip_members(infos, archive_path)
return sum(max(0, int(info.file_size or 0)) for info in infos if not info.is_dir())
if tarfile.is_tarfile(archive_path):
with tarfile.open(archive_path, "r:*") as tar_obj:
members = tar_obj.getmembers()
_validate_tar_members(members, archive_path)
return sum(max(0, int(member.size or 0)) for member in members if member.isfile())
raise ValueError(f"Unsupported GF3 archive format: {archive_path}")
def _ensure_disk_space(target_root: str, required_bytes: int, min_disk_space_gb: float) -> None:
os.makedirs(target_root, exist_ok=True)
_total, _used, free_bytes = shutil.disk_usage(target_root)
min_free_bytes = int(max(0.0, min_disk_space_gb) * (1024 ** 3))
if free_bytes - max(0, int(required_bytes or 0)) < min_free_bytes:
raise OSError(
"GF3 L1A storage has insufficient free space: "
f"needed {required_bytes / (1024 ** 3):.2f} GB, "
f"free {free_bytes / (1024 ** 3):.2f} GB, "
f"min free after {min_disk_space_gb:.2f} GB"
)
def _prepare_atomic_output(output_dir: str, tmp_suffix: str) -> Tuple[bool, str, str]:
tmp_dir = output_dir + tmp_suffix
lock_path = output_dir + ".unpacking"
if os.path.exists(output_dir):
return False, tmp_dir, lock_path
if os.path.exists(tmp_dir):
return False, tmp_dir, lock_path
if os.path.exists(lock_path):
return False, tmp_dir, lock_path
os.makedirs(tmp_dir, exist_ok=False)
with open(lock_path, "w", encoding="utf-8") as stream:
stream.write(datetime.now().isoformat())
return True, tmp_dir, lock_path
def _cleanup_atomic_paths(tmp_dir: str, lock_path: str) -> None:
if os.path.exists(lock_path):
try:
os.remove(lock_path)
except OSError:
pass
if os.path.exists(tmp_dir):
try:
shutil.rmtree(tmp_dir)
except OSError:
pass
def _extract_tar_archive(archive_path: str, tmp_dir: str) -> int:
extracted_files = 0
with tarfile.open(archive_path, "r:*") as tar_obj:
members = tar_obj.getmembers()
_validate_tar_members(members, archive_path)
for member in members:
relative_name = _validate_relative_member_name(member.name, archive_path)
if not relative_name:
continue
destination = _safe_destination(tmp_dir, relative_name)
if member.isdir():
os.makedirs(destination, exist_ok=True)
continue
if not member.isfile():
continue
os.makedirs(os.path.dirname(destination), exist_ok=True)
source = tar_obj.extractfile(member)
if source is None:
raise OSError(f"Failed to read tar member: {member.name}")
with source, open(destination, "wb") as target:
shutil.copyfileobj(source, target, length=1024 * 1024)
extracted_files += 1
return extracted_files
def _extract_zip_archive(archive_path: str, tmp_dir: str) -> int:
extracted_files = 0
with zipfile.ZipFile(archive_path, "r") as zip_obj:
infos = zip_obj.infolist()
_validate_zip_members(infos, archive_path)
for info in infos:
relative_name = _validate_relative_member_name(info.filename, archive_path)
if not relative_name:
continue
destination = _safe_destination(tmp_dir, relative_name)
if info.is_dir():
os.makedirs(destination, exist_ok=True)
continue
os.makedirs(os.path.dirname(destination), exist_ok=True)
with zip_obj.open(info, "r") as source, open(destination, "wb") as target:
shutil.copyfileobj(source, target, length=1024 * 1024)
extracted_files += 1
return extracted_files
def _extract_archive_atomic(archive_path: str, output_dir: str, tmp_suffix: str) -> Tuple[str, int]:
prepared, tmp_dir, lock_path = _prepare_atomic_output(output_dir, tmp_suffix)
if not prepared:
return "EXISTS", 0
try:
if zipfile.is_zipfile(archive_path):
extracted_files = _extract_zip_archive(archive_path, tmp_dir)
elif tarfile.is_tarfile(archive_path):
extracted_files = _extract_tar_archive(archive_path, tmp_dir)
else:
raise ValueError(f"Unsupported GF3 archive format: {archive_path}")
if extracted_files <= 0:
raise OSError("GF3 archive extraction produced no files.")
os.replace(tmp_dir, output_dir)
return "EXTRACTED", extracted_files
finally:
_cleanup_atomic_paths(tmp_dir, lock_path)
def _discover_archives(source_dirs: List[str], extensions: List[str], log_callback: Optional[LogCallback]) -> List[str]:
archives: List[str] = []
normalized_extensions = _normalize_extensions(extensions)
for source_dir in source_dirs:
if not os.path.isdir(source_dir):
message = f"GF3 archive source does not exist or is not a directory: {source_dir}"
logger.warning(message)
if log_callback:
log_callback("WARNING", message)
continue
for root, _dirs, files in os.walk(source_dir):
for file_name in files:
lower_name = file_name.lower()
if any(lower_name.endswith(ext) for ext in normalized_extensions):
archives.append(os.path.join(root, file_name))
return sorted(archives)
def run_gf3_archive_unpack(
*,
source_dirs: Optional[Iterable[str]] = None,
target_dirs: Optional[Iterable[str]] = None,
archive_exts: Optional[Iterable[str]] = None,
max_files_per_run: Optional[int] = None,
delete_archive: Optional[bool] = None,
min_disk_space_gb: Optional[float] = None,
tmp_suffix: Optional[str] = None,
log_callback: Optional[LogCallback] = None,
progress_callback: Optional[ProgressCallback] = None,
) -> Dict[str, Any]:
configured_source_dirs = _normalize_paths(
source_dirs if source_dirs is not None else split_env_paths(settings.GF3_ARCHIVE_SOURCE_DIRS)
)
configured_target_dirs = _normalize_paths(
target_dirs if target_dirs is not None else split_env_paths(settings.GF3_SOURCE_DIRS)
)
extensions = _normalize_extensions(
archive_exts if archive_exts is not None else split_env_paths(settings.GF3_ARCHIVE_EXTS)
)
should_delete_archive = settings.GF3_UNPACK_DELETE_ARCHIVE if delete_archive is None else bool(delete_archive)
limit = max(0, int(max_files_per_run or 0))
min_free_gb = (
_parse_float(os.getenv("UNPACK_MIN_DISK_SPACE_GB"), 50.0)
if min_disk_space_gb is None
else float(min_disk_space_gb)
)
atomic_tmp_suffix = str(tmp_suffix or os.getenv("UNPACK_TMP_SUFFIX") or ".unpack_tmp").strip() or ".unpack_tmp"
if not configured_source_dirs:
raise ValueError("GF3_ARCHIVE_SOURCE_DIRS is not configured.")
if not configured_target_dirs:
raise ValueError("GF3_SOURCE_DIRS is not configured.")
def _log(level: str, message: str) -> None:
logger.log(getattr(logging, level.upper(), logging.INFO), message)
if log_callback:
log_callback(level.upper(), message)
def _progress(progress: int, message: str) -> None:
if progress_callback:
progress_callback(max(0, min(100, int(progress))), message)
_progress(2, "Scanning GF3 archive source directories...")
archives = _discover_archives(configured_source_dirs, extensions, log_callback)
if limit > 0:
archives_to_process = archives[:limit]
else:
archives_to_process = archives
total = len(archives_to_process)
summary: Dict[str, Any] = {
"total": total,
"found": len(archives),
"processed": 0,
"skipped": 0,
"failed": 0,
"remaining": max(0, len(archives) - total),
"source_dirs": configured_source_dirs,
"target_dirs": configured_target_dirs,
"archive_exts": extensions,
"delete_archive": should_delete_archive,
"failures": [],
}
if not archives_to_process:
_progress(100, "No GF3 archives pending.")
summary["message"] = "No GF3 archives found."
return summary
_log("INFO", f"Found {len(archives)} GF3 archives; processing {total}.")
for index, archive_path in enumerate(archives_to_process, start=1):
archive_name = os.path.basename(archive_path)
progress_base = 5 + int(((index - 1) / max(1, total)) * 90)
_progress(progress_base, f"Unpacking GF3 archive {index}/{total}: {archive_name}")
try:
target_root = _resolve_target_root(archive_path, configured_source_dirs, configured_target_dirs)
os.makedirs(target_root, exist_ok=True)
output_name = _strip_archive_extension(os.path.basename(archive_path), extensions)
output_dir = os.path.join(target_root, output_name)
required_bytes = _estimate_archive_size(archive_path)
_ensure_disk_space(target_root, required_bytes, min_free_gb)
status, extracted_files = _extract_archive_atomic(archive_path, output_dir, atomic_tmp_suffix)
if status == "EXISTS":
summary["skipped"] += 1
_log("INFO", f"GF3 archive already unpacked, skipped: {archive_path}")
continue
if should_delete_archive:
os.remove(archive_path)
_log("INFO", f"GF3 archive deleted after successful unpack: {archive_path}")
summary["processed"] += 1
_log("INFO", f"GF3 archive unpacked: {archive_path} -> {output_dir} ({extracted_files} files)")
except Exception as exc:
summary["failed"] += 1
failure = {
"archive_path": archive_path,
"error": str(exc),
}
summary["failures"].append(failure)
_log("ERROR", f"GF3 archive unpack failed: {archive_path}: {exc}")
_progress(100, "GF3 archive unpack completed.")
summary["message"] = (
f"GF3 unpack complete: processed {summary['processed']}, "
f"skipped {summary['skipped']}, failed {summary['failed']}"
)
return summary
+85
View File
@@ -17,6 +17,7 @@ from ..models import (
OrbitAssetORM,
ResultCatalogStateORM,
ResultProductORM,
SARSceneGeoORM,
SceneOrbitBindingORM,
SourceProductAssetORM,
SystemWorkerHeartbeatORM,
@@ -362,6 +363,19 @@ def _sanitize_source_roots_status(payload: Dict[str, Any]) -> Dict[str, Any]:
}
def _sanitize_sar_analysis_ready_status(payload: Dict[str, Any]) -> Dict[str, Any]:
scenes = payload.get("scenes", {}) or {}
roots = payload.get("roots", {}) or {}
return {
"ok": bool(payload.get("ok")),
"configured_root_count": len(roots),
"accessible_root_count": sum(1 for item in roots.values() if item.get("accessible")),
"scene_count": int(scenes.get("scene_count") or 0),
"analysis_scene_count": int(scenes.get("analysis_scene_count") or 0),
"missing_file_count": int(scenes.get("missing_file_count") or 0),
}
def _sanitize_product_package_status(payload: Dict[str, Any]) -> Dict[str, Any]:
return {
"ok": bool(payload.get("ok")),
@@ -460,6 +474,7 @@ def _sanitize_health_status(payload: Dict[str, Any]) -> Dict[str, Any]:
psinsar_result_catalog = timeseries_result_catalog
dinsar_bridge = payload.get("dinsar_bridge", {}) or {}
source_roots = payload.get("source_roots", {}) or {}
sar_analysis_ready = payload.get("sar_analysis_ready", {}) or {}
product_packages = payload.get("product_packages", {}) or {}
asset_inventory = payload.get("asset_inventory", {}) or {}
wsl_runtime = payload.get("wsl_runtime", {}) or {}
@@ -473,6 +488,7 @@ def _sanitize_health_status(payload: Dict[str, Any]) -> Dict[str, Any]:
sanitized_psinsar_catalog = sanitized_timeseries_catalog
sanitized_dinsar_bridge = _sanitize_bridge_status(dinsar_bridge)
sanitized_source_roots = _sanitize_source_roots_status(source_roots)
sanitized_sar_analysis_ready = _sanitize_sar_analysis_ready_status(sar_analysis_ready)
sanitized_product_packages = _sanitize_product_package_status(product_packages)
sanitized_asset_inventory = _sanitize_asset_inventory_status(asset_inventory)
sanitized_wsl_runtime = _sanitize_wsl_runtime_status(wsl_runtime)
@@ -502,6 +518,7 @@ def _sanitize_health_status(payload: Dict[str, Any]) -> Dict[str, Any]:
},
"dinsar_bridge": sanitized_dinsar_bridge,
"source_roots": sanitized_source_roots,
"sar_analysis_ready": sanitized_sar_analysis_ready,
"product_packages": sanitized_product_packages,
"asset_inventory": sanitized_asset_inventory,
"wsl_runtime": sanitized_wsl_runtime,
@@ -869,6 +886,21 @@ async def _check_source_roots() -> Dict[str, Any]:
status["role"] = "dinsar_source"
items.append(status)
for path in split_env_paths(settings.GF3_ARCHIVE_SOURCE_DIRS):
status = _probe_directory_status(path)
status["role"] = "gf3_archive_source"
items.append(status)
for path in split_env_paths(settings.GF3_SOURCE_DIRS):
status = _probe_directory_status(path)
status["role"] = "gf3_l1a_source"
items.append(status)
for path in split_env_paths(settings.GF3_STORAGE_DIRS):
status = _probe_directory_status(path)
status["role"] = "gf3_l2_storage"
items.append(status)
configured_count = len(items)
accessible_count = sum(1 for item in items if item.get("accessible"))
inaccessible_count = configured_count - accessible_count
@@ -882,6 +914,56 @@ async def _check_source_roots() -> Dict[str, Any]:
}
async def _check_sar_analysis_ready() -> Dict[str, Any]:
roots = {
"ready": _probe_directory_status(settings.SAR_ANALYSIS_READY_ROOT),
"work": _probe_directory_status(settings.SAR_ANALYSIS_WORK_ROOT),
"preview": _probe_directory_status(settings.SAR_ANALYSIS_PREVIEW_ROOT),
}
for role, payload in roots.items():
payload["role"] = role
status: Dict[str, Any] = {
"ok": False,
"roots": roots,
"scenes": {
"scene_count": 0,
"analysis_scene_count": 0,
"missing_file_count": 0,
"missing_files": [],
},
"error": None,
}
try:
session_factory = _get_session_factory()
async with session_factory() as db:
scene_count_result = await db.execute(select(func.count(SARSceneGeoORM.id)))
status["scenes"]["scene_count"] = int(scene_count_result.scalar_one() or 0)
analysis_rows_result = await db.execute(
select(SARSceneGeoORM.id, SARSceneGeoORM.analysis_tif_path)
.where(SARSceneGeoORM.analysis_tif_path.is_not(None))
.order_by(SARSceneGeoORM.id.desc())
)
analysis_rows = analysis_rows_result.all()
status["scenes"]["analysis_scene_count"] = len(analysis_rows)
missing_files = []
for scene_id, tif_path in analysis_rows:
path_text = str(tif_path or "").strip()
if path_text and not os.path.isfile(path_text):
missing_files.append({"scene_id": scene_id, "path": path_text})
status["scenes"]["missing_file_count"] = len(missing_files)
status["scenes"]["missing_files"] = missing_files[:20]
status["ok"] = (
all(item.get("accessible") for item in roots.values())
and status["scenes"]["missing_file_count"] == 0
)
except Exception as exc:
status["error"] = str(exc)
return status
async def _check_product_packages() -> Dict[str, Any]:
status = {
"ok": False,
@@ -1256,6 +1338,7 @@ async def get_health_status(
psinsar_result_catalog_status = timeseries_result_catalog_status
dinsar_bridge_status = await _check_dinsar_bridge()
source_roots_status = await _check_source_roots()
sar_analysis_ready_status = await _check_sar_analysis_ready()
product_packages_status = await _check_product_packages()
asset_inventory_status = await _check_asset_inventory()
wsl_runtime_status = await _check_wsl_runtime()
@@ -1273,6 +1356,7 @@ async def get_health_status(
result_catalog_status.get("ok"),
dinsar_bridge_status.get("ok"),
source_roots_status.get("ok"),
sar_analysis_ready_status.get("ok"),
product_packages_status.get("ok"),
asset_inventory_status.get("ok"),
wsl_runtime_status.get("ok"),
@@ -1297,6 +1381,7 @@ async def get_health_status(
},
"dinsar_bridge": dinsar_bridge_status,
"source_roots": source_roots_status,
"sar_analysis_ready": sar_analysis_ready_status,
"product_packages": product_packages_status,
"asset_inventory": asset_inventory_status,
"wsl_runtime": wsl_runtime_status,
+396 -17
View File
@@ -19,7 +19,7 @@ from sqlalchemy import select
from .. import database
from ..config import settings
from ..models import SystemJobORM, DinsarResultORM, HazardPointORM, DinsarTaskItemORM, PsTaskItemORM, RadarDataORM, SARSceneGeoORM, FloodDetectionORM, WaterDetectionORM, GF3ProcessingORM, AiDiagnosisORM
from ..models import SystemJobORM, DinsarResultORM, HazardPointORM, DinsarTaskItemORM, PsTaskItemORM, RadarDataORM, SARSceneGeoORM, FloodDetectionORM, WaterDetectionORM, WaterExtractionORM, GF3ProcessingORM, AiDiagnosisORM
from ..scheduler import scan_data_job
from .data_service import data_service
from .asset_inventory_service import asset_inventory_service
@@ -80,7 +80,10 @@ JOB_TYPE_IDL_RUN_DINSAR = "IDL_RUN_DINSAR"
JOB_TYPE_WATER_GEOCODE = "WATER_GEOCODE"
JOB_TYPE_WATER_FLOOD = "WATER_FLOOD"
JOB_TYPE_WATER_DETECT = "WATER_DETECT"
JOB_TYPE_SAR_SCENE_PREPROCESS = "SAR_SCENE_PREPROCESS"
JOB_TYPE_FLOOD_DETECTION = "FLOOD_DETECTION"
JOB_TYPE_GF3_PROCESS = "GF3_PROCESS"
JOB_TYPE_GF3_UNPACK = "GF3_UNPACK"
JOB_TYPE_GF3_BATCH_PROCESS = "GF3_BATCH_PROCESS"
JOB_TYPE_ISCE2_RUN = "ISCE2_RUN"
JOB_TYPE_PYINT_RUN = "PYINT_RUN"
@@ -88,6 +91,7 @@ JOB_TYPE_PUBLISH_DINSAR_PRODUCTS = "PUBLISH_DINSAR_PRODUCTS"
JOB_TYPE_REBUILD_DINSAR_CATALOG = "REBUILD_DINSAR_CATALOG"
JOB_TYPE_REBUILD_PSINSAR_CATALOG = "REBUILD_PSINSAR_CATALOG"
JOB_TYPE_SCAN_ASSET_INVENTORY = "SCAN_ASSET_INVENTORY"
JOB_TYPE_SBAS_COREGISTRATION = "SBAS_COREGISTRATION"
COPY_ALLOWED_STATUSES = {"PENDING", "IN_PROGRESS", "COMPLETED", "FAILED"}
@@ -3149,6 +3153,106 @@ async def _handle_water_geocode(job: SystemJobORM) -> None:
)
async def _handle_sar_scene_preprocess(job: SystemJobORM) -> None:
"""Build one analysis-ready GeoTIFF for flood/water algorithms."""
payload = job.payload or {}
scene_id = payload.get("scene_id")
radar_data_id = payload.get("radar_data_id")
engine = str(payload.get("engine") or "").strip().lower()
if not scene_id and not radar_data_id:
raise ValueError("SAR_SCENE_PREPROCESS requires scene_id or radar_data_id")
if engine not in {"gf3_gdal", "lt_gamma"}:
raise ValueError(f"Unsupported SAR scene preprocessing engine: {engine}")
await task_service.start_task(job.task_id, message="Preparing analysis-ready SAR GeoTIFF...")
async with AsyncSessionLocal() as db:
scene: SARSceneGeoORM | None = None
if scene_id:
scene = await db.get(SARSceneGeoORM, int(scene_id))
if not scene and radar_data_id:
result = await db.execute(
select(SARSceneGeoORM).where(SARSceneGeoORM.radar_data_id == int(radar_data_id))
)
scene = result.scalar_one_or_none()
if not scene:
scene = SARSceneGeoORM(radar_data_id=int(radar_data_id), status="PENDING")
db.add(scene)
await db.flush()
radar = await db.get(RadarDataORM, int(scene.radar_data_id))
if not radar:
raise ValueError(f"RadarDataORM id={scene.radar_data_id} does not exist")
scene.status = "RUNNING"
scene.error_msg = None
await db.commit()
scene_id = int(scene.id)
radar_data_id = int(radar.id)
try:
if engine == "gf3_gdal":
await task_service.update_task(job.task_id, progress=20, message="Standardizing GF3 L2 GeoTIFF...")
from .sar_analysis_ready_service import standardize_gf3_l2_for_radar
async with AsyncSessionLocal() as db:
manifest = await standardize_gf3_l2_for_radar(
db=db,
radar_id=int(radar_data_id),
l2_path=payload.get("l2_path"),
polarization=payload.get("polarization"),
)
else:
await task_service.update_task(job.task_id, progress=15, message="Running LT Gamma single-scene preprocessing...")
from .lt_gamma_scene_service import run_lt_gamma_scene_preprocess
from .sar_analysis_ready_service import register_analysis_ready_tif
async with AsyncSessionLocal() as db:
scene = await db.get(SARSceneGeoORM, int(scene_id))
radar = await db.get(RadarDataORM, int(radar_data_id))
if not scene or not radar:
raise ValueError("Scene or radar record disappeared before LT Gamma preprocessing")
def _run_lt() -> Dict[str, Any]:
return run_lt_gamma_scene_preprocess(radar=radar, scene=scene, job_id=job.job_id)
lt_manifest = await asyncio.to_thread(_run_lt)
await task_service.update_task(job.task_id, progress=85, message="Registering LT analysis-ready GeoTIFF...")
analysis_tif_path = str(lt_manifest.get("analysis_tif_path") or "").strip()
if not analysis_tif_path:
raise RuntimeError("LT Gamma preprocessing returned no analysis_tif_path")
async with AsyncSessionLocal() as db:
scene = await db.get(SARSceneGeoORM, int(scene_id))
radar = await db.get(RadarDataORM, int(radar_data_id))
if not scene or not radar:
raise ValueError("Scene or radar record disappeared before analysis-ready registration")
manifest = await register_analysis_ready_tif(
db=db,
scene=scene,
radar=radar,
source_tif_path=analysis_tif_path,
engine="lt_gamma",
profile="lt1_gamma_geocoded_mli",
backscatter_unit=str(lt_manifest.get("backscatter_unit") or "gamma_mli_db"),
polarization=radar.polarization,
metadata=lt_manifest,
)
await db.commit()
except Exception as exc:
async with AsyncSessionLocal() as db:
scene = await db.get(SARSceneGeoORM, int(scene_id))
if scene:
scene.status = "FAILED"
scene.error_msg = str(exc)
await db.commit()
raise
await task_service.update_task(
job.task_id,
status="COMPLETED",
progress=100,
message=f"Analysis-ready GeoTIFF ready: {manifest.get('analysis_tif_path')}",
)
async def _handle_water_flood(job: SystemJobORM) -> None:
"""洪涝检测 job handler(灾前 + 灾后配对分类)。"""
from .water_service import run_flood_detection, WATER_RESULTS_DIR
@@ -3171,8 +3275,10 @@ async def _handle_water_flood(job: SystemJobORM) -> None:
raise ValueError("灾前或灾后场景记录不存在")
if pre_scene.status != "DONE" or post_scene.status != "DONE":
raise ValueError("灾前或灾后场景尚未完成地理编码")
pre_geo = pre_scene.geo_path
post_geo = post_scene.geo_path
pre_geo = pre_scene.analysis_tif_path or pre_scene.geo_path
post_geo = post_scene.analysis_tif_path or post_scene.geo_path
if not pre_geo or not post_geo:
raise ValueError("Pre/post scenes must have analysis-ready GeoTIFF paths")
output_dir = os.path.join(WATER_RESULTS_DIR, f"flood_{detection_id}")
os.makedirs(output_dir, exist_ok=True)
@@ -3228,36 +3334,129 @@ async def _handle_water_flood(job: SystemJobORM) -> None:
)
async def _handle_water_detect(job: SystemJobORM) -> None:
"""水体检测 job handler(Otsu + DEM + 形态学 + 连通分量)。"""
from .water_detect_service import run_water_detection
async def _handle_flood_detection(job: SystemJobORM) -> None:
"""Flood-analysis detection job: pure Python GeoTIFF change classification."""
from .flood_detection_service import run_geotiff_flood_detection
payload = job.payload or {}
detection_id = payload.get("detection_id")
if not detection_id:
raise ValueError("WATER_DETECT job 缺少 detection_id")
raise ValueError("FLOOD_DETECTION job requires detection_id")
refine = bool(payload.get("refine", False))
await task_service.start_task(job.task_id, message="Reading flood-detection scene pair...")
async with AsyncSessionLocal() as db:
det = await db.get(FloodDetectionORM, int(detection_id))
if not det:
raise ValueError(f"FloodDetectionORM id={detection_id} does not exist")
pre_scene = await db.get(SARSceneGeoORM, det.pre_scene_id)
post_scene = await db.get(SARSceneGeoORM, det.post_scene_id)
if not pre_scene or not post_scene:
raise ValueError("Pre/post scene records do not exist")
if pre_scene.status != "DONE" or post_scene.status != "DONE":
raise ValueError("Pre/post scenes are not DONE")
pre_tif = pre_scene.analysis_tif_path
post_tif = post_scene.analysis_tif_path
if not pre_tif or not post_tif:
raise ValueError("Flood detection requires analysis-ready GeoTIFF paths for both scenes")
output_dir = os.path.join(settings.WATER_RESULTS_DIR, f"flood_{detection_id}")
os.makedirs(output_dir, exist_ok=True)
await task_service.update_task(job.task_id, progress=10, message="Running GeoTIFF flood classification...")
def _run() -> Dict[str, Any]:
return run_geotiff_flood_detection(
pre_tif_path=pre_tif,
post_tif_path=post_tif,
output_dir=output_dir,
job_id=job.job_id,
refine=refine,
)
try:
result = await asyncio.to_thread(_run)
except Exception as exc:
async with AsyncSessionLocal() as db:
det = await db.get(FloodDetectionORM, int(detection_id))
if det:
det.status = "FAILED"
det.error_msg = str(exc)
await db.commit()
raise
async with AsyncSessionLocal() as db:
det = await db.get(FloodDetectionORM, int(detection_id))
if det:
if result.get("ok"):
det.classified_path = result.get("classified_path")
det.flood_area_km2 = result.get("flood_area_km2")
det.stable_water_area_km2 = result.get("stable_water_area_km2")
det.output_dir = output_dir
det.status = "DONE"
det.error_msg = None
else:
det.status = "FAILED"
det.error_msg = result.get("error", "Unknown error")
await db.commit()
if not result.get("ok"):
raise RuntimeError(f"Flood detection failed: {result.get('error')}")
await task_service.update_task(
job.task_id,
status="COMPLETED",
progress=100,
message=(
f"GeoTIFF flood detection completed: flood_area={result.get('flood_area_km2')} km2, "
f"stable_water={result.get('stable_water_area_km2')} km2"
),
)
async def _handle_water_detect(job: SystemJobORM) -> None:
"""水体检测 job handler(Otsu + DEM + 形态学 + 连通分量)。"""
from .water_extraction_service import run_otsu_water_extraction
payload = job.payload or {}
extraction_id = payload.get("extraction_id")
detection_id = payload.get("detection_id")
record_id = extraction_id or detection_id
if not record_id:
raise ValueError("WATER_DETECT job 缺少 extraction_id/detection_id")
use_extraction_table = extraction_id is not None
await task_service.start_task(job.task_id, message="读取检测任务信息...")
async with AsyncSessionLocal() as db:
det = await db.get(WaterDetectionORM, int(detection_id))
det = await db.get(WaterExtractionORM if use_extraction_table else WaterDetectionORM, int(record_id))
if not det:
raise ValueError(f"WaterDetectionORM id={detection_id} 不存在")
model_name = "WaterExtractionORM" if use_extraction_table else "WaterDetectionORM"
raise ValueError(f"{model_name} id={record_id} 不存在")
input_path = det.input_path
det.status = "RUNNING"
if use_extraction_table and hasattr(det, "task_id"):
det.task_id = job.task_id
if not use_extraction_table:
mirror = await db.get(WaterExtractionORM, int(record_id))
if mirror:
mirror.status = "RUNNING"
mirror.task_id = job.task_id
await db.commit()
if not input_path:
raise ValueError("水体检测缺少输入路径 input_path")
output_dir = os.path.join(os.path.dirname(input_path), f"water_detect_{detection_id}")
output_name = f"water_extraction_{record_id}" if use_extraction_table else f"water_detect_{record_id}"
output_dir = os.path.join(os.path.dirname(input_path), 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]:
return run_water_detection(
geo_tiff_path=input_path,
return run_otsu_water_extraction(
input_path=input_path,
output_dir=output_dir,
job_id=job.job_id,
)
@@ -3266,26 +3465,56 @@ async def _handle_water_detect(job: SystemJobORM) -> None:
result = await asyncio.to_thread(_run)
except Exception as exc:
async with AsyncSessionLocal() as db:
det = await db.get(WaterDetectionORM, int(detection_id))
det = await db.get(WaterExtractionORM if use_extraction_table else WaterDetectionORM, int(record_id))
if det:
det.status = "FAILED"
det.error_msg = str(exc)
await db.commit()
if not use_extraction_table:
mirror = await db.get(WaterExtractionORM, int(record_id))
if mirror:
mirror.status = "FAILED"
mirror.error_msg = str(exc)
mirror.task_id = job.task_id
await db.commit()
raise
async with AsyncSessionLocal() as db:
det = await db.get(WaterDetectionORM, int(detection_id))
det = await db.get(WaterExtractionORM if use_extraction_table else WaterDetectionORM, int(record_id))
if det:
if result.get("ok"):
det.output_path = result.get("output_path")
det.water_area_km2 = result.get("water_area_km2")
det.water_pixel_count = result.get("water_pixel_count")
det.otsu_threshold_db = result.get("otsu_threshold_db")
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"),
"job_id": job.job_id,
}
else:
det.otsu_threshold_db = result.get("otsu_threshold_db")
det.status = "DONE"
det.error_msg = None
else:
det.status = "FAILED"
det.error_msg = result.get("error", "Unknown error")
if not use_extraction_table:
mirror = await db.get(WaterExtractionORM, int(record_id))
if mirror:
mirror.output_path = det.output_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")
mirror.processor = result.get("processor") or mirror.processor or "otsu"
mirror.status = det.status
mirror.error_msg = det.error_msg
mirror.task_id = job.task_id
mirror.metadata_json = {
"legacy_otsu_threshold_db": result.get("otsu_threshold_db"),
"legacy_detection_id": int(record_id),
"job_id": job.job_id,
}
await db.commit()
if not result.get("ok"):
@@ -3376,9 +3605,70 @@ async def _handle_gf3_process(job: SystemJobORM) -> None:
)
async def _handle_gf3_unpack(job: SystemJobORM) -> None:
"""GF3 archive inbox -> persistent L1A source pool."""
from .gf3_unpack_service import run_gf3_archive_unpack
if not job.task_id:
raise ValueError("GF3_UNPACK requires task_id for progress tracking.")
payload = job.payload or {}
await task_service.start_task(job.task_id, message="扫描 GF3 压缩包来源目录...")
loop = asyncio.get_running_loop()
def _submit(coro):
try:
future = asyncio.run_coroutine_threadsafe(coro, loop)
except RuntimeError:
return
def _swallow_errors(fut):
try:
fut.result()
except Exception as exc:
logger.warning("[GF3 Unpack] task callback failed: %s", exc)
future.add_done_callback(_swallow_errors)
def _log_cb(level: str, message: str) -> None:
_submit(task_service.add_log(job.task_id, level, message))
def _progress_cb(progress: int, message: str) -> None:
_submit(task_service.update_task(job.task_id, progress=progress, message=message))
try:
result = await asyncio.to_thread(
run_gf3_archive_unpack,
source_dirs=payload.get("source_dirs"),
target_dirs=payload.get("target_dirs"),
archive_exts=payload.get("archive_exts"),
max_files_per_run=payload.get("max_files_per_run"),
delete_archive=payload.get("delete_archive") if "delete_archive" in payload else None,
min_disk_space_gb=payload.get("min_disk_space_gb"),
tmp_suffix=payload.get("tmp_suffix"),
log_callback=_log_cb,
progress_callback=_progress_cb,
)
except Exception as exc:
await task_service.update_task(job.task_id, status="FAILED", progress=100, message=f"GF3 解包失败: {exc}")
raise
message = (
f"GF3 解包完成: 成功 {int(result.get('processed') or 0)}, "
f"跳过 {int(result.get('skipped') or 0)}, "
f"失败 {int(result.get('failed') or 0)}"
)
remaining = int(result.get("remaining") or 0)
if remaining > 0:
message += f", 剩余 {remaining}"
await task_service.update_task(job.task_id, status="COMPLETED", progress=100, message=message)
async def _handle_gf3_batch_process(job: SystemJobORM) -> None:
"""批量 GF3 L1A→L2:扫描来源目录,逐个处理并自动入库到 radar_data。"""
from .gf3_service import run_gf3_l1a_to_l2, register_l2_to_radar_data
from .sar_analysis_ready_service import standardize_gf3_l2_for_radar
payload = job.payload or {}
source_dirs = payload.get("source_dirs") or []
@@ -3458,12 +3748,18 @@ async def _handle_gf3_batch_process(job: SystemJobORM) -> None:
# Auto-register to radar_data
try:
async with AsyncSessionLocal() as db:
await register_l2_to_radar_data(
radar_id = await register_l2_to_radar_data(
l2_dir=result.get("output_dir", output_dir),
input_dir_name=dir_name,
polarizations=result.get("polarizations", []),
db=db,
)
if radar_id:
await standardize_gf3_l2_for_radar(
db=db,
radar_id=int(radar_id),
l2_path=result.get("output_dir", output_dir),
)
except Exception as reg_err:
logger.warning("[GF3 Batch] Auto-register failed for %s: %s", dir_name, reg_err)
else:
@@ -3888,6 +4184,85 @@ async def _handle_rebuild_psinsar_catalog(job: SystemJobORM) -> None:
)
async def _handle_sbas_coregistration(job: SystemJobORM) -> None:
if not job.task_id:
raise ValueError("SBAS_COREGISTRATION requires task_id for progress tracking.")
payload = job.payload or {}
run_id = str(payload.get("run_id") or "").strip()
if not run_id:
raise ValueError("SBAS_COREGISTRATION requires run_id payload.")
rlks = _normalize_positive_int(payload.get("rlks")) or 8
azlks = _normalize_positive_int(payload.get("azlks")) or 8
timeout_seconds = _normalize_positive_int(payload.get("timeout_seconds")) or 43200
await task_service.start_task(job.task_id, message="正在执行 SBAS-InSAR Gamma 共参考配准...")
await task_service.update_task(
job.task_id,
progress=5,
message=f"准备运行 Gamma SLC_coreg.py: run_id={run_id}",
)
await task_service.add_log(
job.task_id,
"INFO",
f"SBAS coregistration queued: run_id={run_id}, rlks={rlks}, azlks={azlks}, timeout={timeout_seconds}s",
)
from .sbas_insar_production_service import sbas_insar_production_service
async def _task_keepalive() -> None:
progress = 12
while True:
await asyncio.sleep(60)
progress = min(88, progress + 2)
await task_service.update_task(
job.task_id,
progress=progress,
message=f"Gamma 共参考配准仍在运行: run_id={run_id}",
)
runner_task = asyncio.create_task(
asyncio.to_thread(
sbas_insar_production_service.execute_coregistration,
run_id,
rlks=rlks,
azlks=azlks,
timeout_seconds=timeout_seconds,
)
)
keepalive_task = asyncio.create_task(_task_keepalive())
try:
result = await runner_task
finally:
keepalive_task.cancel()
try:
await keepalive_task
except asyncio.CancelledError:
pass
manifest = result.get("manifest") or {}
run = result.get("run") or {}
summary = (manifest.get("coregistration") or {}).get("summary") or {}
status = str(run.get("status") or manifest.get("status") or "").strip()
if status != "COREGISTRATION_READY":
raise RuntimeError(
"SBAS coregistration failed: "
f"status={status or 'UNKNOWN'}, "
f"missing_dates={summary.get('missing_dates') or []}, "
f"missing_tabs={summary.get('missing_tabs') or []}"
)
await task_service.update_task(
job.task_id,
status="COMPLETED",
progress=100,
message=(
"SBAS-InSAR 共参考配准完成: "
f"{summary.get('ready_secondary_count', 0)}/{summary.get('expected_secondary_count', 0)} secondary scenes ready"
),
)
_HANDLERS = {
JOB_TYPE_SCAN_DATA: _handle_scan_data,
JOB_TYPE_SCAN_ASSET_INVENTORY: _handle_scan_asset_inventory,
@@ -3918,10 +4293,14 @@ _HANDLERS = {
JOB_TYPE_ISCE2_RUN: _handle_isce2_run,
JOB_TYPE_PYINT_RUN: _handle_pyint_run,
JOB_TYPE_WATER_GEOCODE: _handle_water_geocode,
JOB_TYPE_SAR_SCENE_PREPROCESS: _handle_sar_scene_preprocess,
JOB_TYPE_WATER_FLOOD: _handle_water_flood,
JOB_TYPE_FLOOD_DETECTION: _handle_flood_detection,
JOB_TYPE_WATER_DETECT: _handle_water_detect,
JOB_TYPE_GF3_PROCESS: _handle_gf3_process,
JOB_TYPE_GF3_UNPACK: _handle_gf3_unpack,
JOB_TYPE_GF3_BATCH_PROCESS: _handle_gf3_batch_process,
JOB_TYPE_SBAS_COREGISTRATION: _handle_sbas_coregistration,
}
@@ -0,0 +1,158 @@
"""LT single-scene Gamma preprocessing service."""
from __future__ import annotations
import json
import os
import re
from pathlib import Path
from typing import Any
from ..config import settings
from ..models import RadarDataORM, SARSceneGeoORM
from .pyint_service import (
DEFAULT_AZIMUTH_LOOKS,
DEFAULT_RANGE_LOOKS,
quote_shell,
resolve_gamma_env_script,
to_wsl_path,
)
from .wsl_service import run_wsl_exec
def _safe_token(value: Any, *, default: str = "scene") -> str:
text = str(value or "").strip()
if not text:
text = default
text = re.sub(r"[^0-9A-Za-z._-]+", "_", text).strip("._-")
return text or default
def _scene_date(radar: RadarDataORM) -> str:
for value in (radar.imaging_date, radar.file_path, radar.unique_id):
text = str(value or "")
match = re.search(r"(20\d{6})", re.sub(r"\D", "", text))
if match:
return match.group(1)
match = re.search(r"(20\d{6})", text)
if match:
return match.group(1)
raise ValueError(f"Cannot infer LT scene date for radar_data id={radar.id}")
def _prepared_dem_path() -> str:
if str(settings.PYINT_DEM_MODE or "").strip().lower() == "prepared_file":
return (
settings.PYINT_PREPARED_DEM_PATH
or settings.ISCE2_DEM_PATH
or settings.IDL_DINSAR_DEM_BASE_FILE
or ""
)
return ""
def _build_shell_command(parts: list[str]) -> str:
return " ".join(quote_shell(part) for part in parts)
def _runner_script() -> Path:
return Path(settings.PROJECT_ROOT) / "backend" / "app" / "pyint_pipeline" / "run_gamma_scene_preprocess.py"
def run_lt_gamma_scene_preprocess(
*,
radar: RadarDataORM,
scene: SARSceneGeoORM,
job_id: str | None = None,
) -> dict[str, Any]:
if not settings.PYINT_ENABLED:
raise RuntimeError("PYINT_ENABLED=false; LT Gamma scene preprocessing is disabled")
if not radar.file_path:
raise ValueError(f"RadarDataORM id={radar.id} has no file_path")
date = _scene_date(radar)
token = _safe_token(radar.unique_id or Path(str(radar.file_path)).stem or f"radar_{radar.id}")
run_name = _safe_token(f"lt_{date}_{token}_scene_{scene.id}_{job_id or 'manual'}")
work_dir = Path(settings.SAR_ANALYSIS_WORK_ROOT) / "lt_gamma" / run_name
output_dir = work_dir / "output"
work_dir.mkdir(parents=True, exist_ok=True)
output_dir.mkdir(parents=True, exist_ok=True)
pyint_home = settings.PYINT_HOME
if not pyint_home:
raise RuntimeError("PYINT_HOME is not configured")
pyint_python = settings.PYINT_WSL_PYTHON or settings.WSL_SHARED_PYTHON
if not pyint_python:
raise RuntimeError("PYINT_WSL_PYTHON is not configured")
runner = _runner_script()
if not runner.is_file():
raise FileNotFoundError(f"Gamma scene runner not found: {runner}")
args = [
pyint_python,
to_wsl_path(str(runner)),
"--source-path",
to_wsl_path(str(radar.file_path)),
"--output-dir",
to_wsl_path(str(output_dir)),
"--work-dir",
to_wsl_path(str(work_dir)),
"--pyint-home",
to_wsl_path(str(pyint_home)),
"--dem-root",
to_wsl_path(str(settings.PYINT_DEM_ROOT)),
"--prepared-dem-path",
to_wsl_path(_prepared_dem_path()),
"--project-name",
run_name,
"--date",
date,
"--satellite-family",
"LT1",
"--range-looks",
str(DEFAULT_RANGE_LOOKS),
"--azimuth-looks",
str(DEFAULT_AZIMUTH_LOOKS),
"--geo-interp",
str(settings.PYINT_GEO_INTERP or "1"),
"--nodata-value",
str(float(settings.SAR_ANALYSIS_NODATA_VALUE)),
"--to-db",
]
gamma_env_script = resolve_gamma_env_script(settings.PYINT_GAMMA_ENV_SCRIPT)
prefix = ""
if gamma_env_script:
prefix = f". {quote_shell(to_wsl_path(gamma_env_script))} >/dev/null 2>&1 || exit 1; "
command = prefix + f"export PYTHONPATH={quote_shell(to_wsl_path(str(pyint_home)))}:$PYTHONPATH; " + _build_shell_command(args)
rc, stdout, stderr = run_wsl_exec(
["bash", "-lc", command],
distro=settings.PYINT_WSL_DISTRO or settings.WSL_DISTRO,
timeout=int(settings.PYINT_DEFAULT_TIMEOUT_SECONDS or 43200),
)
if rc != 0:
detail = (stderr or stdout or "").strip()
raise RuntimeError(f"LT Gamma scene preprocessing failed rc={rc}: {detail}")
manifest_path = output_dir / "manifest.json"
if not manifest_path.is_file():
raise RuntimeError(f"LT Gamma scene preprocessing produced no manifest: {manifest_path}")
try:
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
except Exception as exc:
raise RuntimeError(f"Cannot read LT Gamma manifest: {manifest_path}: {exc}") from exc
analysis_tif_path = manifest.get("analysis_tif_path")
if analysis_tif_path and str(analysis_tif_path).startswith("/mnt/"):
# The service registers the Windows-side path below.
manifest["analysis_tif_path_wsl"] = analysis_tif_path
manifest["analysis_tif_path"] = str(output_dir / "analysis_ready.tif")
manifest["service"] = {
"work_dir": str(work_dir),
"output_dir": str(output_dir),
"job_id": job_id,
"stdout": stdout[-4000:] if stdout else "",
"stderr": stderr[-4000:] if stderr else "",
}
return manifest
@@ -235,6 +235,15 @@ def _build_root_specs_from_settings() -> List[RootSpec]:
scan_mode="directory_walk",
)
)
specs.extend(
_iter_multi_root_specs(
env_var="GF3_ARCHIVE_SOURCE_DIRS",
paths=split_env_paths(settings.GF3_ARCHIVE_SOURCE_DIRS),
root_role="source_pool_gf3_archive",
display_prefix="GF3 Archive Pool",
scan_mode="archive_walk",
)
)
specs.extend(
_iter_multi_root_specs(
env_var="GF3_SOURCE_DIRS",
@@ -253,6 +262,24 @@ def _build_root_specs_from_settings() -> List[RootSpec]:
scan_mode="scene_directory",
)
)
specs.extend(
_iter_single_root_specs(
env_var="SAR_ANALYSIS_READY_ROOT",
path=settings.SAR_ANALYSIS_READY_ROOT,
root_role="sar_analysis_ready",
display_name="SAR Analysis-ready GeoTIFF Root",
scan_mode="scene_directory",
)
)
specs.extend(
_iter_single_root_specs(
env_var="SAR_ANALYSIS_WORK_ROOT",
path=settings.SAR_ANALYSIS_WORK_ROOT,
root_role="sar_analysis_work",
display_name="SAR Analysis Work Root",
scan_mode="directory_walk",
)
)
orbit_source_paths = split_env_paths(settings.ORBIT_SOURCE_DIRS)
if not orbit_source_paths:
orbit_source_paths = split_env_paths(settings.MONITOR_ORBIT_DIR)
@@ -0,0 +1,360 @@
"""Analysis-ready SAR GeoTIFF registration for flood/water algorithms.
This service owns the common contract between satellite-specific preprocessing
and downstream flood/water algorithms: one geocoded, single-band GeoTIFF plus
sidecar metadata under SAR_ANALYSIS_READY_ROOT.
"""
from __future__ import annotations
import json
import math
import os
import re
import shutil
from pathlib import Path
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..models import RadarDataORM, SARSceneGeoORM
from ..utils import normalize_satellite_family
_SAFE_TEXT_RE = re.compile(r"[^0-9A-Za-z._-]+")
_POLARIZATION_PRIORITY = ("HH", "VV", "HV", "VH")
def _safe_slug(value: Any, *, default: str = "unknown") -> str:
text = str(value or "").strip()
if not text:
text = default
text = _SAFE_TEXT_RE.sub("_", text).strip("._-")
return text or default
def _scene_family(radar: RadarDataORM | None) -> str:
family = normalize_satellite_family(
getattr(radar, "satellite_family", None) or getattr(radar, "satellite", None)
)
return _safe_slug(family or "SAR").upper()
def _scene_date(radar: RadarDataORM | None) -> str:
text = str(getattr(radar, "imaging_date", None) or "").strip()
match = re.search(r"(20\d{6})", re.sub(r"\D", "", text))
if match:
return match.group(1)
return "unknown_date"
def _scene_token(
*,
radar: RadarDataORM | None,
scene: SARSceneGeoORM,
polarization: str | None = None,
) -> str:
unique = getattr(radar, "unique_id", None) or f"radar_{getattr(radar, 'id', scene.radar_data_id)}"
parts = [_scene_date(radar), _safe_slug(unique), f"scene_{scene.id}"]
if polarization:
parts.append(_safe_slug(polarization).upper())
return "_".join(parts)
def scene_analysis_dir(
*,
radar: RadarDataORM | None,
scene: SARSceneGeoORM,
engine: str,
profile: str,
polarization: str | None = None,
) -> Path:
return (
Path(settings.SAR_ANALYSIS_READY_ROOT)
/ _scene_family(radar)
/ _safe_slug(engine)
/ _safe_slug(profile)
/ _scene_date(radar)
/ _scene_token(radar=radar, scene=scene, polarization=polarization)
)
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as stream:
json.dump(payload, stream, ensure_ascii=False, indent=2, default=str)
def _link_or_copy(source: Path, target: Path) -> str:
target.parent.mkdir(parents=True, exist_ok=True)
if source.resolve() == target.resolve():
return "same_path"
if target.exists():
target.unlink()
try:
os.link(source, target)
return "hardlink"
except OSError:
shutil.copy2(source, target)
return "copy"
def _choose_gf3_l2_tif(l2_dir: str, polarization: str | None = None) -> Path:
root = Path(os.path.normpath(str(l2_dir or "").strip()))
if root.is_file():
return root
if not root.is_dir():
raise FileNotFoundError(f"GF3 L2 directory does not exist: {l2_dir}")
candidates = sorted(
path
for path in root.rglob("*")
if path.is_file()
and path.suffix.lower() in {".tif", ".tiff"}
and "L2" in path.name.upper()
)
if not candidates:
raise FileNotFoundError(f"No GF3 L2 GeoTIFF found in: {l2_dir}")
requested = str(polarization or "").strip().upper()
if requested:
for path in candidates:
if requested in path.name.upper():
return path
for pol in _POLARIZATION_PRIORITY:
for path in candidates:
if pol in path.name.upper():
return path
return candidates[0]
def _infer_polarization_from_path(path: Path) -> str | None:
upper_name = path.name.upper()
for pol in _POLARIZATION_PRIORITY:
if pol in upper_name:
return pol
return None
def _raster_quality(path: Path) -> dict[str, Any]:
try:
import numpy as np
import rasterio
except Exception as exc:
return {"ok": False, "warning": f"rasterio unavailable: {exc}"}
with rasterio.open(path) as src:
if src.height > 2048 or src.width > 2048:
scale = min(1024 / src.width, 1024 / src.height)
out_width = max(1, int(src.width * scale))
out_height = max(1, int(src.height * scale))
sampled = src.read(1, out_shape=(out_height, out_width), masked=True)
else:
sampled = src.read(1, masked=True)
valid = sampled.compressed() if hasattr(sampled, "compressed") else sampled[np.isfinite(sampled)]
bounds = src.bounds
transform = src.transform
quality: dict[str, Any] = {
"ok": True,
"driver": src.driver,
"width": src.width,
"height": src.height,
"count": src.count,
"dtype": str(src.dtypes[0]) if src.dtypes else None,
"crs": src.crs.to_string() if src.crs else None,
"bounds": {
"left": bounds.left,
"bottom": bounds.bottom,
"right": bounds.right,
"top": bounds.top,
},
"transform": list(transform)[:6],
"nodata": src.nodata,
"valid_sample_count": int(valid.size),
"valid_sample_percent": float(valid.size / sampled.size) if sampled.size else 0.0,
}
if valid.size:
quality.update(
{
"sample_min": float(np.nanmin(valid)),
"sample_max": float(np.nanmax(valid)),
"sample_mean": float(np.nanmean(valid)),
"sample_p02": float(np.nanpercentile(valid, 2)),
"sample_p98": float(np.nanpercentile(valid, 98)),
}
)
return quality
def _pixel_size_m_from_quality(quality: dict[str, Any]) -> float | None:
try:
transform = quality.get("transform") or []
xres = abs(float(transform[0]))
yres = abs(float(transform[4]))
crs = str(quality.get("crs") or "").upper()
if not xres or not yres:
return None
if crs and "4326" not in crs:
return round((xres + yres) / 2.0, 3)
bounds = quality.get("bounds") or {}
lat = (float(bounds.get("bottom", 0.0)) + float(bounds.get("top", 0.0))) / 2.0
meters_per_degree_lon = 111320.0 * max(0.01, math.cos(math.radians(lat)))
x_m = xres * meters_per_degree_lon
y_m = yres * 110540.0
return round((x_m + y_m) / 2.0, 3)
except Exception:
return None
def _build_preview_png(source: Path, target: Path) -> str | None:
try:
import numpy as np
import rasterio
from PIL import Image
except Exception:
return None
target.parent.mkdir(parents=True, exist_ok=True)
with rasterio.open(source) as src:
if src.height > 1600 or src.width > 1600:
scale = min(1600 / src.width, 1600 / src.height)
out_width = max(1, int(src.width * scale))
out_height = max(1, int(src.height * scale))
band = src.read(1, out_shape=(out_height, out_width), masked=True)
else:
band = src.read(1, masked=True)
data = band.filled(np.nan).astype("float32")
valid = data[np.isfinite(data)]
if valid.size:
p2, p98 = np.nanpercentile(valid, [2, 98])
normalized = np.clip((data - p2) / max(p98 - p2, 1e-6), 0, 1)
gray = (normalized * 255).astype("uint8")
else:
gray = np.zeros(data.shape, dtype="uint8")
alpha = np.where(np.isfinite(data), 255, 0).astype("uint8")
rgba = np.stack([gray, gray, gray, alpha], axis=-1)
Image.fromarray(rgba, "RGBA").save(target)
return str(target)
async def _get_or_create_scene(db: AsyncSession, radar_id: int) -> SARSceneGeoORM:
result = await db.execute(select(SARSceneGeoORM).where(SARSceneGeoORM.radar_data_id == radar_id))
scene = result.scalar_one_or_none()
if scene:
return scene
scene = SARSceneGeoORM(radar_data_id=radar_id, status="PENDING")
db.add(scene)
await db.flush()
return scene
async def register_analysis_ready_tif(
*,
db: AsyncSession,
scene: SARSceneGeoORM,
radar: RadarDataORM | None,
source_tif_path: str,
engine: str,
profile: str,
backscatter_unit: str,
polarization: str | None = None,
metadata: dict[str, Any] | None = None,
copy_mode: str = "link_or_copy",
) -> dict[str, Any]:
source = Path(os.path.normpath(str(source_tif_path or "").strip()))
if not source.is_file():
raise FileNotFoundError(f"Analysis-ready source GeoTIFF does not exist: {source}")
out_dir = scene_analysis_dir(
radar=radar,
scene=scene,
engine=engine,
profile=profile,
polarization=polarization,
)
target_tif = out_dir / "analysis_ready.tif"
transfer = "none"
if copy_mode == "reference":
target_tif = source
else:
transfer = _link_or_copy(source, target_tif)
quality = _raster_quality(target_tif)
preview_path = _build_preview_png(target_tif, out_dir / "preview.png")
manifest = {
"scene_id": scene.id,
"radar_data_id": scene.radar_data_id,
"source_tif_path": str(source),
"analysis_tif_path": str(target_tif),
"analysis_dir": str(out_dir),
"analysis_preview_path": preview_path,
"engine": engine,
"profile": profile,
"backscatter_unit": backscatter_unit,
"polarization": polarization,
"transfer": transfer,
"metadata": metadata or {},
"quality": quality,
}
_write_json(out_dir / "manifest.json", manifest)
_write_json(out_dir / "quality.json", quality)
scene.geo_path = str(target_tif)
scene.analysis_tif_path = str(target_tif)
scene.analysis_dir = str(out_dir)
scene.analysis_preview_path = preview_path
scene.analysis_engine = engine
scene.analysis_profile = profile
scene.analysis_backscatter_unit = backscatter_unit
nodata_value = quality.get("nodata")
scene.analysis_nodata_value = (
float(nodata_value)
if nodata_value is not None
else float(settings.SAR_ANALYSIS_NODATA_VALUE)
)
scene.analysis_metadata_json = {**(metadata or {}), "manifest_path": str(out_dir / "manifest.json")}
scene.analysis_quality_json = quality
scene.pixel_size_m = _pixel_size_m_from_quality(quality) or scene.pixel_size_m
scene.status = "DONE"
scene.error_msg = None
return manifest
async def standardize_gf3_l2_for_radar(
*,
db: AsyncSession,
radar_id: int,
l2_path: str | None = None,
polarization: str | None = None,
) -> dict[str, Any]:
radar = await db.get(RadarDataORM, int(radar_id))
if not radar:
raise ValueError(f"RadarDataORM id={radar_id} does not exist")
scene = await _get_or_create_scene(db, int(radar_id))
source_root = l2_path or radar.file_path
selected_tif = _choose_gf3_l2_tif(source_root, polarization=polarization or radar.polarization)
selected_pol = polarization or _infer_polarization_from_path(selected_tif)
manifest = await register_analysis_ready_tif(
db=db,
scene=scene,
radar=radar,
source_tif_path=str(selected_tif),
engine="gf3_gdal",
profile="gf3_l1a_l2_rpc",
backscatter_unit="sigma0_db",
polarization=selected_pol,
metadata={
"source": "GF3 L2",
"source_l2_path": str(selected_tif),
"source_l2_dir": str(Path(source_root).resolve()) if source_root else None,
"available_polarization": radar.polarization,
},
)
await db.commit()
return manifest
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,33 @@
"""Water extraction processors for the flood-analysis pipeline."""
from __future__ import annotations
from typing import Any
def run_otsu_water_extraction(
*,
input_path: str,
output_dir: str,
job_id: str | None = None,
) -> dict[str, Any]:
"""Run the fast Otsu water-extraction processor.
This wraps the legacy implementation while exposing the terminology used by
the flood pipeline: extraction, processor and threshold_value.
"""
from .water_detect_service import run_water_detection
result = run_water_detection(
geo_tiff_path=input_path,
output_dir=output_dir,
job_id=job_id,
)
result["processor"] = "otsu"
if "threshold_value" not in result and "otsu_threshold_db" in result:
result["threshold_value"] = result.get("otsu_threshold_db")
return result
def run_envi_water_extraction(*args: Any, **kwargs: Any) -> dict[str, Any]:
"""Placeholder for the future ENVI/SARscape precise extractor."""
raise NotImplementedError("ENVI/SARscape water extraction is not wired yet")
@@ -0,0 +1,515 @@
# 洪涝模块 GeoTIFF 化与 GAMMA 前处理评估
更新日期:2026-05-15
## 1. 结论
洪涝模块后续应把业务算法全部下放到标准 GeoTIFF 层:
```text
原始 SAR 数据
-> 可插拔前处理器:GAMMA 优先,其他处理器兜底
-> 分析级 GeoTIFF
-> Python 水体提取 / 洪涝检测 / 套合分析 / 产品生成
```
ENVI/SARscape 不再作为洪涝算法主线。它可以短期保留为历史兼容处理器,但不应继续决定数据模型、接口命名和算法逻辑。
GAMMA 适合作为第一候选前处理底座,原因是当前项目已经具备 WSL、PyINT、GAMMA 环境注入、DEM、`geocode_back`、`data2geotiff` 和 D-InSAR 生产发布经验。但现有 GAMMA/PyINT 接入是 D-InSAR pair 流程,不是洪涝需要的“单景分析级 GeoTIFF 预处理服务”。因此不能直接把 `lt1_gamma_dinsar` 或 `s1_gamma_dinsar` 原样接到洪涝预处理按钮上,必须新增单景前处理适配层。
## 2. 当前真实情况
### 2.1 洪涝模块当前仍绑定 ENVI/SARscape
当前 `/flood/preprocess` 最终仍执行旧的 `water_service.run_geocoding_workflow()`,内部流程是:
```text
SARsImportLuTan1
-> SARsBasicMultilooking
-> SARsBasicGeocoding
```
输出写入 `SARSceneGeoORM.geo_path`,语义是 SARscape 地理编码 dB 影像 base path,不是标准 GeoTIFF 交付物。
当前 `/flood/detections` 后台任务仍调用 `water_service.run_flood_detection()`,内部流程是:
```text
SARsBasicFeFloodingClassification
-> 可选 SARsBasicFeFloodingClassificationRefinement
-> rasterio 统计分类结果面积
```
分类值约定是:
```text
0 = 无数据 / 背景
1 = 稳定水体
2 = 洪涝 / 新增水体
3 = 高散射点
4 = 非水体
```
这说明当前“洪涝检测算法”实际是 SARscape 黑盒,不是平台自有算法。
### 2.2 已经接近目标形态的部分
以下部分已经更接近“标准栅格 + Python 算法”的目标:
```text
水体提取:
water_extraction_service -> water_detect_service
Otsu + DEM/坡度约束 + 形态学 + 连通分量过滤
套合分析:
flood_overlay_service
读取 classified 栅格,class=2 转矢量,套合灾害点、DInSAR 产品和 AOI
GF3:
gf3_service
Python/GDAL 完成 L1A -> L2:辐射定标 + RPC 几何校正
```
其中 GF3 路线虽然不走 GAMMA,但输出理念已经是“处理到 GeoTIFF 后进入平台算法”。它应该作为 `external/gdal` 前处理器接入统一 Scene,而不是继续挂在 `/water/gf3-process` 下。
### 2.3 当前 GAMMA/PyINT 接入情况
当前仓库里的 GAMMA/PyINT 能力主要服务 D-InSAR 生产:
```text
backend/app/dinsar_engines/pyint_engine.py
- lt1_gamma_dinsar
- s1_gamma_dinsar
backend/app/pyint_pipeline/run_lt1_pyint_pipeline.py
- LT-1 托管 runner
- geocode_gamma.py
- data2geotiff
- 标准产品发布
backend/app/pyint_pipeline/run_s1_pyint_pipeline.py
- Sentinel-1 runner
- 复用 LT-1 runner 的日志、发布和质量检查框架
deploy/wsl/profiles/gamma_env.sh
- 注入 GAMMA_HOME
- 注入 GAMMA 各模块 bin/scripts
- 注入 third_party/PyINT/pyint
```
当前 `.env` 中 PyINT/GAMMA 是启用状态:
```text
PYINT_ENABLED=true
PYINT_WSL_DISTRO=Ubuntu-24.04
PYINT_WSL_PYTHON=/home/administrator/miniconda3/envs/insar_wsl_v1/bin/python
PYINT_DEM_MODE=prepared_file
PYINT_PREPARED_DEM_PATH=D:\DEM\SRTMDEM_RSP_SARscape.wgs84
PYINT_GEO_INTERP=1
PYINT_GAMMA_NODATA_VALUE=-9999.0
```
需要注意一个不一致点:`pyint_engine.py` 已经暴露 `s1_gamma_dinsar`,但 `wsl_runtime_registry.py` 当前允许的 PyINT operation 仍只有 `lt1_gamma_dinsar` 和 `gamma_refine`。如果后续洪涝或生产任务走 WSL Broker,需要同步补齐允许列表。
### 2.4 GAMMA 现有输出与洪涝需求的差距
现有 D-InSAR GAMMA 链已经能导出 GeoTIFF,但输出重点是 pair 产品:
```text
disp.tif
disp_unmasked.tif
coh.tif
wrapped_phase.tif
vertical displacement
look vector
```
`hyp3format_gamma.py` 也能把 `geocode_gamma.py` 产生的 geocoded GAMMA 二进制导出为 GeoTIFF,包含:
```text
amp
corr
dem
unw_phase
wrapped_phase
los_disp
vert_disp
lv_theta
lv_phi
```
但洪涝模块真正需要的是单景产品:
```text
analysis_ready.tif
- 单景后向散射强度
- 明确单位:sigma0_db / gamma0_db / power_db
- 明确极化:VV / VH / HH / HV
- 明确 CRS、分辨率、nodata、覆盖范围
- 可与另一景重采样到同一网格
```
当前没有一个独立的 `gamma_single_scene_preprocess` 服务稳定地产出上述文件。现有 D-InSAR 流程里的 `geo_<master>_*.amp` 可以证明 GAMMA 有地理编码 MLI/AMP 的能力,但它嵌在 pair 配准和干涉流程中,不能直接作为洪涝单景预处理接口。
## 3. 目标数据契约
后续洪涝模块不再以 `geo_path` 表示模糊的“地理编码产物”,而应引入明确的分析级 Scene 产物语义。
建议模型命名:
```text
SarAnalysisScene / AnalysisReadyScene
```
最小字段:
```text
id
radar_data_id
engine # gamma / gf3_gdal / snap / envi_legacy / external
engine_profile # lt1_gamma_scene / s1_gamma_scene / gf3_rpc_l2
analysis_tif_path # 主分析 GeoTIFF
preview_path
coverage_polygon
satellite
satellite_family # LT1 / S1 / GF3 / ...
imaging_date
polarization
orbit_direction
relative_orbit
backscatter_unit # sigma0_db / gamma0_db / power_db / unknown
crs
pixel_size_x
pixel_size_y
nodata_value
grid_id # 同网格配对时使用
dem_path
incidence_angle_tif_path # 可选
layover_shadow_mask_path # 可选
quality_json
lineage_json
status
error_msg
created_at
updated_at
```
输出文件要求:
```text
analysis_ready.tif:
- GeoTIFF,优先 COG
- float32
- nodata = -9999
- 推荐值域为 dB
- rasterio/GDAL 可直接读取
preview.png:
- 仅用于地图快视
metadata.json:
- 记录处理器、命令、输入资产、DEM、参数、软件版本、质量检查
```
## 4. 目标流水线
### 4.1 前处理器只负责到 GeoTIFF 为止
前处理器接口建议统一为:
```text
submit_scene_preprocess(radar_data_id, engine="gamma", profile="auto")
```
处理器职责:
```text
1. 识别输入资产和传感器类型。
2. 导入原始产品到处理器内部格式。
3. 做必要的辐射定标。
4. 做多视 / 滤波 / speckle 控制。
5. 使用 DEM 做地形校正或地理编码。
6. 导出 analysis_ready.tif。
7. 生成 coverage、preview、metadata、quality。
8. 注册 SarAnalysisScene。
```
处理器不负责:
```text
1. 水体提取。
2. 洪涝分类。
3. 洪涝矢量化。
4. 灾害点套合。
5. 报告生成。
```
### 4.2 洪涝算法只认 GeoTIFF
水体提取:
```text
analysis_ready.tif
-> 读取 dB 栅格
-> 有效像元 / nodata / DEM / 坡度约束
-> Otsu 或自适应阈值
-> 形态学与连通域过滤
-> water_mask.tif + water_vector.geojson
```
洪涝检测:
```text
pre analysis_ready.tif
post analysis_ready.tif
-> 检查 CRS / 分辨率 / 极化 / 覆盖范围
-> 重采样到同一网格
-> 分别生成 pre_water_mask / post_water_mask
-> post_water && !pre_water = 洪涝 / 新增水体
-> pre_water && post_water = 稳定水体
-> 结合 dB 差值、DEM 坡度、连通域和面积阈值过滤
-> classified.tif
```
建议分类值继续沿用现有约定,降低前端和套合分析改动量:
```text
0 = nodata
1 = stable_water
2 = flood
3 = high_scatter_or_uncertain
4 = non_water
```
套合分析:
```text
classified.tif
-> class=2 转矢量
-> 灾害点命中
-> 近洪涝风险点
-> DInSAR 产品 footprint 相交
-> AOI / 行政区面积统计
```
## 5. GAMMA 适配性判断
| 能力项 | 当前情况 | 适配判断 |
| --- | --- | --- |
| WSL/GAMMA 环境 | 已有 `gamma_env.sh`,能注入 GAMMA 与 PyINT 路径 | 可复用 |
| LT-1 导入 | 已有 `down2slc_LT1_*` 与精轨桥接,历史上处理过几何问题 | 可作为第一批验证对象,但需专项 QA |
| Sentinel-1 导入 | PyINT 脚本支持 ZIP、EOF、burst、TOPS 相关流程,项目已有 `s1_gamma_dinsar` runner | 可做第二批,但 runtime operation 白名单需补齐 |
| GF3 | 当前项目已有 Python/GDAL L1A->L2,不依赖 GAMMA | 不建议强行走 GAMMA,先走 `gf3_gdal` 处理器 |
| 地理编码 | 已有 `geocode_gamma.py`、`geocode_back`、DEM 生成链 | 可复用思想和部分脚本 |
| GeoTIFF 导出 | 已有 `data2geotiff`、HyP3/标准产品导出经验 | 可复用 |
| 单景分析级输出 | 当前没有独立服务,D-InSAR 中只顺带产出 master amp | 需要新建 |
| 辐射定标语义 | Sentinel-1 导入使用 calibration XML;LT-1/GF3 各有差异 | 必须在 metadata 中明确单位,不能含糊写 dB |
| 生产稳定性 | 当前 D-InSAR runner 已有日志、repair、quality、manifest | 可复用框架,但不能复用 pair 假设 |
结论:GAMMA 适合做洪涝模块的优先前处理引擎,但第一阶段目标应是:
```text
LT-1 单景 -> GAMMA -> analysis_ready.tif
```
跑通并验证后再扩展:
```text
Sentinel-1 ZIP -> GAMMA -> analysis_ready.tif
GF3 L1A -> gf3_gdal -> analysis_ready.tif
```
## 6. 不应采用的方案
### 6.1 不应继续以 ENVI/SARscape 作为洪涝主线
原因:
```text
1. 数据格式和任务对象绑定 SARscape。
2. 洪涝分类是黑盒,难以解释和调参。
3. 服务器部署依赖重。
4. 后续多源数据扩展会反复补导入适配器。
5. 输出不天然适合平台统一发布。
```
### 6.2 不应把洪涝算法绑定到 GAMMA
GAMMA 应只负责把原始 SAR 处理成标准 GeoTIFF。洪涝算法不能依赖 GAMMA 中间二进制,也不能要求输入来自 GAMMA。否则只是把“绑定 ENVI”换成“绑定 GAMMA”。
正确边界是:
```text
GAMMA / GF3_GDAL / SNAP / ISCE / external
-> analysis_ready.tif
-> 同一套 Python 洪涝算法
```
### 6.3 不应直接复用 D-InSAR pair profile 做单景预处理
现有 `lt1_gamma_dinsar` / `s1_gamma_dinsar` 是 pair 生产 profile,包含:
```text
master/slave
coreg
diff
unwrap
geocode pair products
disp/coh export
```
洪涝单景预处理只需要:
```text
single scene import
radiometric calibration
multilook/filter
terrain correction/geocoding
GeoTIFF export
QA/register
```
两者不能混成一个 profile。
## 7. 建议实施阶段
### Phase 1:建立 GeoTIFF 数据契约
```text
1. 新建或扩展 Scene 表,明确 analysis_tif_path。
2. 保留旧 SARSceneGeoORM.geo_path 兼容字段。
3. /flood/scenes 改为优先返回 analysis_tif_path。
4. 前端把“可处理”判断改为是否存在 analysis_ready scene。
```
### Phase 2:先把洪涝检测改成 Python GeoTIFF 算法
```text
1. 新建 flood_detection_service.py。
2. 输入 pre_scene.analysis_tif_path 和 post_scene.analysis_tif_path。
3. 对齐网格。
4. 复用水体提取算法生成 pre/post water mask。
5. 输出 classified.tif。
6. 统计 flood_area_km2 和 stable_water_area_km2。
```
这一步可以先用 ENVI 现有 geocode 结果或 GF3 L2 GeoTIFF 做输入测试,目的是先解除洪涝分类对 SARscape 的依赖。
### Phase 3:新增 GAMMA LT-1 单景前处理器
建议新增:
```text
backend/app/services/sar_scene_preprocess_service.py
backend/app/services/gamma_scene_preprocess_service.py
backend/app/pyint_pipeline/run_gamma_scene_preprocess.py
```
最小 runner:
```text
1. stage LT-1 输入资产。
2. 调用 LT-1 导入脚本,产出 SLC。
3. multi_look 生成 MLI/AMP。
4. generate_rdc_dem 或等价 DEM 几何。
5. geocode_back 把 AMP 映射到地理网格。
6. data2geotiff 输出 analysis_ready.tif。
7. 可选转 COG。
8. rasterio 质量检查。
```
### Phase 4:接 Sentinel-1 和 GF3
Sentinel-1:
```text
1. 复用 Sentinel-1 ZIP/SAFE/EOF 管理设计。
2. 新增 s1_gamma_scene profile。
3. 不走 diff/unwrap,只产出单景 analysis_ready.tif。
4. 修正 WSL runtime operation 白名单。
```
GF3:
```text
1. 保留 gf3_service.py 的 GDAL/RPC 路线。
2. 输出注册为 analysis_ready scene。
3. 不强行改成 GAMMA。
```
### Phase 5:退役 ENVI 洪涝主线
```text
1. `/flood/detections` 默认使用 GeoTIFF 算法。
2. ENVI/SARscape 作为 `envi_legacy` 处理器保留一段时间。
3. 新任务不再默认创建 SARscape 洪涝分类。
4. 文档、界面和部署说明移除“ENVI 是洪涝必须项”的表述。
```
## 8. 验收标准
### 8.1 Scene 前处理
```text
1. 任意一个完成的 analysis_ready.tif 可以被 rasterio 打开。
2. CRS、bounds、transform、nodata 完整。
3. 有效像元比例达到阈值。
4. dB 值域在合理范围内。
5. metadata.json 能追溯输入资产、处理器、DEM、命令和版本。
6. preview 和 coverage 能在地图上显示和清除。
```
### 8.2 洪涝检测
```text
1. pre/post 两个 GeoTIFF 可自动对齐到同一网格。
2. classified.tif 只依赖 Python/rasterio/numpy/scipy/skimage,不调用 ENVI/SARscape。
3. class=1/2 面积统计稳定。
4. 套合分析可直接读取 classified.tif。
5. 同一算法能跑 GAMMA TIF、GF3 L2 TIF 和其他外部 TIF。
```
### 8.3 GAMMA 适配
```text
1. LT-1 单景 GAMMA 前处理能稳定产出 analysis_ready.tif。
2. 失败时有明确阶段日志,不出现“任务成功但全 0”的假成功。
3. data2geotiff 输出通过 GDAL/rasterio 质量检查。
4. 处理器输出不要求后续算法知道 GAMMA 中间目录结构。
```
## 9. 对部署的影响
Git 可以提供平台代码、Python 算法、任务编排、数据库迁移和前端能力,但不能内置 GAMMA 商业软件本体。另一台服务器部署时必须额外满足:
```text
1. WSL 或 Linux 环境可用。
2. GAMMA 安装路径可被 gamma_env.sh 发现,或配置 PYINT_GAMMA_HOME/GAMMA_HOME。
3. PyINT 代码随仓库存在。
4. WSL Python/conda 环境具备 rasterio、GDAL、numpy、scipy、skimage 等依赖。
5. DEM、轨道池和源数据池路径在 .env 中配置。
```
如果目标服务器没有 GAMMA,系统仍应允许:
```text
1. 使用已经存在的 analysis_ready.tif。
2. 使用 GF3_GDAL 处理器。
3. 后续接 SNAP/ISCE/external 处理器。
4. 继续执行水体提取、洪涝检测、套合分析和产品生成。
```
这也是把算法下放到 GeoTIFF 层的核心价值。
## 2026-05-16 implementation note
The first code pass now implements LT1 and GF3 as analysis-ready scene preprocessors:
- New config roots: `SAR_ANALYSIS_READY_ROOT`, `SAR_ANALYSIS_WORK_ROOT`, `SAR_ANALYSIS_PREVIEW_ROOT`.
- `sar_scene_geo` now stores `analysis_tif_path`, `analysis_dir`, preview path, engine/profile, backscatter unit, nodata, metadata and quality JSON.
- `/flood/preprocess` routes GF3 scenes to `gf3_gdal` standardization and LT1 scenes to `lt_gamma` single-scene preprocessing.
- GF3 uses the existing GDAL/RPC L1A->L2 result, then registers the selected L2 GeoTIFF under `SAR_ANALYSIS_READY_ROOT`.
- LT1 uses a new Gamma/PyINT runner that stops at single-scene geocoded MLI GeoTIFF and does not write into D-InSAR product directories.
- Water extraction, flood detection and scene previews now prefer `analysis_tif_path` and fall back to legacy `geo_path`.
- Startup database maintenance now treats Alembic `0004` as the managed head, can auto-add missing ORM columns/indexes, and records the `alembic_version` marker after the schema is healthy.
- `/health?full=true&refresh=true` now includes `sar_analysis_ready` root and scene-path checks.
This keeps D-InSAR product publication isolated from flood/water analysis inputs.
+693
View File
@@ -0,0 +1,693 @@
# 洪涝监测模块整改实施方案
> 日期:2026-05-14
> 目标:将当前分散的水体监测、洪涝检测、GF3 处理和结果管理,收敛为可部署、可维护、可扩展的洪涝灾害分析流水线。
## 1. 总体原则
1. 新业务统一走 `/flood/*`。
2. “水体监测”不再作为独立业务模块出现,只作为“洪涝灾害分析 -> 水体提取”步骤存在。
3. `/water/*` 暂时保留兼容窗口,但标记 deprecated,不再新增功能。
4. 数据库优先新增表和回填数据,避免直接破坏旧表。
5. 每个阶段独立提交,保证任一阶段都能构建、部署和回滚。
## 2. 目标流水线
```text
场景准备 Scene
-> 水体提取 WaterExtraction
-> 洪涝检测 FloodDetection
-> 套合分析 FloodOverlay
-> 洪涝产品 FloodProduct / Report
```
其中水体提取是洪涝分析的前置步骤,不再和洪涝灾害分析并列成两个业务入口。
## 3. 阶段 1:收敛 API 契约
目标:先消灭“前端调用不存在接口”的问题,避免部署后出现静默 404。
修改范围:
```text
frontend/src/api/flood.js
backend/app/routers/flood.py
docs/FLOOD_MODULE_REFACTOR_PLAN_20260514.md
```
处理方式:
- `frontend/src/api/flood.js` 只暴露后端当前真实支持的接口。
- 暂时移除或注释未实现接口,例如 `/flood/sources`、`/flood/ready-products`、`/flood/pairs` 保存删除、`/flood/reports`、`/flood/results`。
- 后端 `/flood` 路由继续保留现有功能,但接口命名统一为 `preprocess`、`scenes`、`water-extractions`、`pairs/search`、`detections`、`detections/{id}/preview/{layer}`。
验收标准:
```text
npm run build 通过
前端 flood API 文件中没有明显会 404 的已导出函数
/flood 主流程现有功能不退化
```
## 4. 阶段 2:新增数据模型
目标:建立洪涝流水线需要的数据承载,不直接破坏旧表。
新增模型:
```text
WaterExtractionORM -> water_extractions
FloodOverlayORM -> flood_overlays
FloodProductORM -> flood_products
```
关键取舍:
- 保留旧 `WaterDetectionORM -> water_detections`。
- 新表 `water_extractions` 从旧表回填。
- 后续新任务写入 `water_extractions`。
- 旧接口读旧表或兼容映射,等稳定后再清理。
建议字段:
```text
water_extractions:
id
scene_id
processor
task_id
input_path
output_path
preview_path
vector_path
water_area_km2
water_pixel_count
threshold_value
metadata_json
status
error_msg
created_at
updated_at
flood_overlays:
id
detection_id
flood_vector_path
hazard_points_hit
hazard_points_near
hazard_points_total
dinsar_products_intersecting
affected_area_km2
summary_json
created_at
flood_products:
id
product_id
detection_id
overlay_id
display_name
status
publish_dir
manifest_path
summary_json
created_at
```
验收标准:
```text
alembic upgrade head 成功
旧 water_detections 数据可迁移到 water_extractions
模型 import 正常
```
## 5. 阶段 3:抽离 Service
目标:让 `/flood` 成为真正的业务路由,而不是代理 `water.py` 的壳。
新增服务:
```text
backend/app/services/flood_analysis_service.py
backend/app/services/water_extraction_service.py
backend/app/services/flood_product_service.py
backend/app/services/flood_overlay_service.py
```
职责划分:
```text
flood_analysis_service.py
场景列表、预处理提交、配对搜索、洪涝检测提交、检测列表
water_extraction_service.py
Otsu 水体提取、ENVI/SARscape 水体提取
flood_product_service.py
产品列表、manifest、产品包生成
flood_overlay_service.py
分类栅格矢量化、灾害点/DInSAR/AOI 套合
```
完成后:
- `backend/app/routers/flood.py` 不再 `import water as water_compat`。
- `backend/app/routers/water.py` 标记 deprecated,后续可反向调用新 service。
- `water_detect_service.py` 逐步迁移到 `water_extraction_service.py`。
验收标准:
```text
flood.py 不再 import water.py
/water/* 旧接口仍可用
/flood/* 主接口可用
```
## 6. 阶段 4:补齐产品端点
目标:让“结果与任务”视图有真实后端数据。
主接口建议使用 `products`,避免和 DInSAR result/product 概念混淆:
```text
POST /flood/detections/{id}/products
GET /flood/products
GET /flood/products/{id}
GET /flood/products/{id}/manifest
```
兼容别名可选:
```text
GET /flood/results
GET /flood/results/{id}
GET /flood/results/{id}/manifest
```
验收标准:
```text
前端结果视图能展示真实产品
manifest 能返回 JSON
没有空接口或静默失败
```
## 7. 阶段 5:实现套合分析
目标:把洪涝模块从检测工具提升为灾害分析模块。
新增接口:
```text
POST /flood/detections/{id}/overlay
GET /flood/detections/{id}/impact
```
处理逻辑:
```text
1. 读取 flood_detections.classified_path
2. 提取 class=2 洪涝区域
3. 栅格转矢量
4. 写 flood_overlays.flood_vector_path
5. 查询灾害点命中
6. 查询近邻风险点
7. 查询相交 DInSAR 产品
8. 查询 AI 诊断摘要
9. 写 summary_json
```
注意:
- AOI、PostGIS、DInSAR 产品几何不完整时,不让整个任务失败。
- 返回部分结果,并在 `summary_json.warnings` 中说明缺失项。
验收标准:
```text
POST overlay 能生成 flood_overlays 记录
GET impact 返回结构化 JSON
没有数据时返回空数组而不是 500
```
## 8. 阶段 6:前端重整
目标:只保留一个洪涝灾害分析工作台。
新增目录:
```text
frontend/src/components/flood/
```
建议组件:
```text
FloodStatusBadge.jsx
FloodButton.jsx
FloodSceneRow.jsx
FloodWaterExtractionRow.jsx
FloodDetectionRow.jsx
FloodOverlayPanel.jsx
FloodProductPanel.jsx
```
界面改成四站式:
```text
1. 场景准备
2. 水体提取
3. 洪涝检测
4. 套合与产品
```
处理方式:
- `WaterMonitorPanel.jsx` 加弃用提示。
- 左侧导航只引导用户进入洪涝灾害分析。
- `api/water.js` 和 `api/gf3.js` 标记 deprecated。
- 新功能只接入 `api/flood.js`。
验收标准:
```text
左侧导航只引导用户进入洪涝灾害分析
水体提取不再作为独立业务重复出现
套合分析和产品视图接真实接口
npm run build 通过
```
## 9. 阶段 7:报告生成壳
目标:先形成 Markdown 报告能力,再扩展 PDF。
新增接口:
```text
POST /flood/reports
GET /flood/reports/{id}
```
第一版报告包含:
```text
灾前/灾后场景信息
洪涝面积
灾害点命中
DInSAR 产品关联
AI 诊断摘要
套合统计
```
## 10. 推荐提交顺序
```text
1. refactor flood api contract
2. add flood pipeline orm models
3. extract flood analysis services
4. add flood product endpoints
5. implement flood overlay impact analysis
6. consolidate flood frontend workspace
7. scaffold flood report generation
```
核心思路:先稳住接口和数据,再拆服务,再补业务能力,最后整理 UI。这样 Git 中的代码、迁移、前端调用和部署环境是闭合的,另一台服务器拉取后不会出现关键页面依赖未实现接口的问题。
## 11. 2026-05-15 补充设计:水体提取、灾害配对与套合展示
本节根据最新审阅意见补充。结论:洪涝工作台不应该继续以“手动选日期范围 + 查配对”为核心,而应该改成“灾害事件驱动”:
```text
灾害时间 + 灾害位置
-> 过滤可用 SAR 场景匹配池
-> 推荐灾前/灾后配对
-> 执行洪涝检测
-> 套合分析
-> 结果展示与产品导出
```
### 11.1 水体提取视图复用管理页能力
当前管理页面已经具备三类能力,洪涝模块应复用,而不是重新做一套弱化版:
1. 雷达数据查询能力:`/radar-data/search` 已支持成像日期、成像模式、极化方式、产品级别、行政区 AOI、上传 AOI 文件。
2. 地图能力:`App.jsx` 中已经有源影像 footprint 上图、源影像预览缓存 `radar-data/{id}/thumb`、地图定位和图层开关逻辑。
3. 行政区 AOI 能力:`/aoi/regions/children` 与 `/aoi/regions/{treeId}/geometry` 已可用于按省/市范围查询和定位。
因此水体提取界面调整为:
```text
左侧:场景匹配池
- 灾害位置 / AOI
- 成像日期范围
- 卫星、模式、极化、产品级别
- 只显示有 footprint 的数据
- 可显示源影像预览图
- 显示成像时间、极化方式、成像模式、产品级别
右侧:已完成地理编码场景 / 水体提取结果
- 场景 footprint 上图
- 水体提取掩膜上图
- 与源影像预览可叠加对比
```
前端复用建议:
```text
复用 buildRadarSearchFormData / normalizeRadarSearchCriteria
复用 UnifiedDatePicker
复用 RadarDataRow 的预览状态表达
复用 App.jsx 中 updateRadarPreviewVisibility 的源影像预览图层逻辑
复用行政区 AOI 选择和地图定位逻辑
```
需要新增的洪涝专用组件:
```text
FloodSourceSearchPanel.jsx
封装灾害位置、日期、极化、模式、产品级别过滤。
FloodSourceSceneRow.jsx
显示源影像:成像时间、极化方式、模式、产品级别、预览状态、上图按钮。
FloodWaterExtractionRow.jsx
显示水体提取结果:面积、状态、输入场景、上图按钮、错误信息。
```
地图图层约定:
```text
source_preview:{radar_data_id} 源影像预览
source_footprint:{radar_data_id} 源影像覆盖范围
scene_footprint:{scene_id} 地理编码场景范围
water_mask:{water_extraction_id} 水体提取掩膜
```
### 11.2 洪涝检测配对改为灾害事件驱动
当前 `/flood/pairs/search` 只接收灾前/灾后日期区间和 overlap 阈值,实际使用体验不够:用户通常知道的是“灾害发生时间”和“灾害位置”,不是一开始就知道灾前灾后影像窗口。
新的配对入口应改为:
```text
灾害名称 disaster_name 可选
灾害时间 disaster_date 必填
灾害位置 disaster_aoi 必填,来自行政区 / 地图框选 / 上传 SHP / GeoJSON
灾前窗口 pre_window_days 默认 30 天
灾后窗口 post_window_days 默认 30 天
最小 AOI 覆盖率 min_aoi_coverage_ratio 默认 0.3
最小两景重叠率 min_pair_overlap_ratio 默认 0.5
是否要求同极化 require_same_polarization 默认 true
是否要求同成像模式 require_same_imaging_mode 默认 false
卫星过滤 satellites 可选
极化过滤 polarization 可选
```
前端交互:
```text
1. 用户选择灾害日期
2. 用户选择灾害位置
- 行政区:省 / 市
- 地图框选:后续实现
- 上传 SHP/GeoJSON:沿用现有 AOI 解析
3. 系统自动推导:
- 灾前窗口:disaster_date - pre_window_days 至 disaster_date - 1
- 灾后窗口:disaster_date 至 disaster_date + post_window_days
4. 后端返回:
- pre_pool
- post_pool
- candidate_pairs
- warnings
5. 用户在候选配对中选择一组提交洪涝检测
```
推荐新增接口:
```text
POST /flood/disaster-pairs/search
```
请求格式:
```json
{
"disaster_name": "汶川洪涝",
"disaster_date": "20260715",
"aoi_geojson": {},
"region_tree_id": "510000",
"pre_window_days": 30,
"post_window_days": 30,
"min_aoi_coverage_ratio": 0.3,
"min_pair_overlap_ratio": 0.5,
"require_same_polarization": true,
"require_same_imaging_mode": false,
"satellites": ["LT-1", "GF3"],
"polarization": "VV"
}
```
返回格式:
```json
{
"disaster": {
"name": "汶川洪涝",
"date": "20260715",
"pre_range": ["20260615", "20260714"],
"post_range": ["20260715", "20260814"]
},
"aoi": {
"source": "region",
"name": "汶川县",
"aoi_geojson": {}
},
"pre_pool": [],
"post_pool": [],
"candidate_pairs": [
{
"pre": {
"scene_id": 1,
"radar_data_id": 10,
"imaging_date": "20260701",
"satellite": "LT-1",
"polarization": "VV",
"imaging_mode": "SM",
"aoi_coverage_ratio": 0.82,
"coverage_polygon": []
},
"post": {
"scene_id": 2,
"radar_data_id": 11,
"imaging_date": "20260718",
"satellite": "LT-1",
"polarization": "VV",
"imaging_mode": "SM",
"aoi_coverage_ratio": 0.79,
"coverage_polygon": []
},
"pair_overlap_ratio": 0.91,
"pre_delta_days": 14,
"post_delta_days": 3,
"score": 0.86,
"warnings": []
}
],
"warnings": []
}
```
排序建议:
```text
score =
pair_overlap_ratio * 0.35
+ min(pre_aoi_coverage, post_aoi_coverage) * 0.30
+ time_score * 0.20
+ same_polarization_bonus * 0.10
+ same_mode_bonus * 0.05
```
后端实现建议:
```text
1. 复用 radar search 的 AOI 解析能力,避免重复解析行政区和 SHP。
2. 查询 sar_scene_geo.status=DONE 的场景,并 join radar_data。
3. 按 disaster_date 自动构造灾前/灾后窗口。
4. 用 PostGIS 计算单景 AOI 覆盖率。
5. 用 footprint 相交面积计算 pair_overlap_ratio。
6. 返回匹配池和候选配对,而不是只返回最终 pairs。
```
兼容处理:
```text
旧接口 POST /flood/pairs/search 保留,但只作为手动日期模式。
新工作台默认使用 POST /flood/disaster-pairs/search。
```
### 11.3 套合分析结果展示逻辑
套合分析不能只是一个“运行按钮”。它应展示“为什么这个洪涝结果重要”:
```text
洪涝范围
灾害点命中
近洪涝风险点
DInSAR 产品关联
行政区影响统计
结果图层
产品/报告入口
```
前端 `FloodOverlayPanel.jsx` 设计:
```text
顶部摘要:
- 洪涝面积
- 命中灾害点数量
- 近洪涝风险点数量
- 关联 DInSAR 产品数量
- warnings 数量
中部地图控制:
- 洪涝分类图
- 洪涝矢量范围
- 命中灾害点
- 近洪涝风险点
- DInSAR 产品 footprint
下部结果表:
- 灾害点列表:名称、类型、行政区、距离、是否命中
- DInSAR 产品列表:product_id、engine、形变量、AI 风险等级、预览/详情
- AOI 统计:行政区、洪涝面积、占比
```
后端 `GET /flood/detections/{id}/impact` 应保证即使没有运行套合,也返回稳定结构:
```json
{
"detection_id": 1,
"flood_area_km2": 12.5,
"hazard_points": {
"inside_flood": [],
"near_flood": [],
"total_in_scene": 0
},
"dinsar_products": [],
"affected_aois": [],
"map_layers": {
"classified_preview": true,
"flood_vector_path": null,
"hazard_points": true,
"dinsar_footprints": true
},
"warnings": []
}
```
需要新增或完善的地图层约定:
```text
flood_classified:{detection_id} 洪涝分类栅格预览
flood_vector:{overlay_id} 洪涝矢量面
hazard_inside:{overlay_id} 洪涝范围内灾害点
hazard_near:{overlay_id} 近洪涝风险点
dinsar_intersect:{overlay_id} 相交 DInSAR 产品 footprint
```
### 11.4 前端整改顺序调整
基于以上补充,前端整改顺序调整为:
```text
1. 抽 FloodSourceSearchPanel,复用管理页雷达搜索/AOI 查询。
2. 水体提取页增加源影像预览图、成像时间、极化方式、模式、产品级别。
3. 洪涝检测页改为灾害事件输入:灾害时间 + 灾害位置。
4. 新增 /flood/disaster-pairs/search 后接入候选池和推荐配对。
5. 套合分析页接入 /flood/detections/{id}/overlay 和 /impact。
6. 结果与任务页从 flood_detections 过渡到 flood_products。
```
验收标准补充:
```text
水体提取:
- 能按行政区/上传 AOI 查询源影像
- 能显示源影像预览图
- 列表中显示成像日期、极化方式、成像模式、产品级别
- 水体提取结果可叠加到地图
洪涝检测:
- 用户只需输入灾害时间和灾害位置即可获得匹配池
- 返回 pre_pool/post_pool/candidate_pairs
- 推荐配对可在地图上预览灾前/灾后 footprint
- 配对结果显示时间差、AOI 覆盖率、两景重叠率、极化一致性
套合分析:
- 运行后能看到摘要指标
- 能看到灾害点命中和近邻风险点列表
- 能看到关联 DInSAR 产品列表
- warnings 可见,不静默失败
```
## 12. 2026-05-15 第一轮落地记录
本轮先闭合三条可直接使用的业务链路,不把报告生成和完整产品包导出放进同一次改动。
### 12.1 已落地
后端:
```text
1. 新增 POST /flood/disaster-pairs/search。
输入 disaster_date + region_tree_id/aoi_geojson + 灾前灾后窗口。
输出 pre_pool、post_pool、candidate_pairs、summary、warnings。
2. 水体提取列表补充源影像元数据。
/flood/scenes 与 /flood/water-extractions 现在返回 imaging_date、polarization、
imaging_mode、product_level、coverage_polygon、min/max lon/lat。
3. 套合分析接口保持并接入前端。
POST /flood/detections/{id}/overlay
GET /flood/detections/{id}/impact
```
前端:
```text
1. 洪涝灾害分析 / 水体提取页:
- 复用 AOI 行政区索引。
- 支持按行政区筛选雷达源影像。
- 雷达结果行增加“覆盖”和“源影像”上图。
- 场景/水体结果行显示成像日期、极化方式、模式、产品级别。
2. 洪涝检测页:
- 从手工填写灾前/灾后日期,改为输入灾害名称、灾害发生日期、灾害位置。
- 自动根据窗口期生成灾前池和灾后池。
- 候选配对展示评分、AOI 覆盖率、两景重叠率、灾前/灾后时间差、极化方式。
3. 套合分析页:
- 可运行 overlay。
- 可刷新并展示 impact。
- 展示洪涝面积、命中灾害点、近洪涝风险点、关联 DInSAR 产品和 warnings。
```
### 12.2 当前保留的兼容点
```text
1. GET/POST /water/* 暂未删除,旧页面仍可过渡使用。
2. 旧 POST /flood/pairs/search 保留为手工日期兼容接口,但新工作台默认使用 /flood/disaster-pairs/search。
3. 水体提取任务底层仍写 water_detections;water_extractions 新表和正式迁移已建模,但任务处理器尚未完全切换。
4. AOI 只先复用行政区查询;上传 SHP/GeoJSON 到洪涝工作台可作为下一步补充。
```
### 12.3 下一轮建议
```text
1. 把水体提取 job handler 从 WaterDetectionORM 切到 WaterExtractionORM。
2. 给候选配对增加地图 footprint 预览按钮,辅助人工确认灾前/灾后覆盖。
3. 把 overlay 生成的 flood_vector_path 也作为地图矢量层显示。
4. 套合分析补行政区受影响面积统计。
5. 结果与任务页开始接 flood_products,而不是只列 flood_detections。
```
@@ -0,0 +1,128 @@
# 洪涝模块整改进展记录
## 2026-05-15 第二轮:水体提取表切换
本轮目标是把洪涝工作台里的水体提取正式从旧 `water_detections`
迁到新 `water_extractions`,同时保留旧 `/water/*` 兼容窗口。
### 已完成
```text
1. 新增 backend/app/services/water_extraction_service.py。
- run_otsu_water_extraction 复用旧 Otsu 实现。
- 对外使用 extraction/processor/threshold_value 命名。
- run_envi_water_extraction 暂留占位,后续接 ENVI/SARscape。
2. /flood/water-extractions 新提交任务开始写 water_extractions。
- 任务 payload 使用 extraction_id。
- WaterExtractionORM.processor 默认 otsu。
- task_id、threshold_value、metadata_json 随任务更新。
3. WATER_DETECT job handler 支持双轨兼容。
- 新 payload: extraction_id -> 写 water_extractions。
- 旧 payload: detection_id -> 继续写 water_detections。
- 如果旧 detection_id 已经被 alembic 0003 回填到 water_extractions,
handler 会同步更新同 ID 的新表记录,避免迁移期状态卡住。
4. /flood/water-extractions 列表改读 water_extractions。
- 旧数据依赖 alembic 0003 从 water_detections 回填。
- preview 优先读 WaterExtractionORM,找不到时回退 WaterDetectionORM。
```
### 仍保留
```text
1. 旧 /water/detect 仍创建 water_detections。
这是兼容窗口内的刻意保留,不再作为洪涝工作台主链路。
2. ENVI/SARscape 精密水体提取还没有接入任务队列。
3. 如果部署环境已有旧 PENDING water_detections 任务,
需要先执行 alembic 0003,再启动 worker。
```
### 下一步
```text
1. 给候选洪涝配对增加灾前/灾后 footprint 地图预览。
2. 把 flood_vector_path 作为矢量图层上图。
3. 套合分析补行政区受影响面积统计。
4. 结果与任务页接 flood_products。
```
## 2026-05-15 第三轮:地图闭环与产品页
### 已完成
```text
1. 候选洪涝配对支持地图预览。
- 灾前 footprint 蓝色。
- 灾后 footprint 绿色。
- 配对行增加“预览覆盖”。
2. 套合结果支持洪涝矢量上图。
- GET /flood/detections/{id}/impact 返回 overlay_id、flood_vector_path、flood_vector_geojson。
- 前端套合分析页增加“加载洪涝矢量”。
3. 套合分析补行政区影响统计。
- overlay 运行时读取 AOI 行政区边界索引。
- 返回 affected_aois,包含 tree_id、name、level、flood_area_km2。
- 前端展示影响行政区数量和前 5 个行政区。
4. 结果与任务页接 flood_products。
- 洪涝结果行增加“生成产品”。
- 结果页增加“产品”tab。
- 产品行展示 product_id、洪涝面积、影响面积、生成时间。
- Manifest 按钮读取 /flood/products/{id}/manifest。
```
### 当前可集中测试的链路
```text
1. 水体提取:
入库影像查询 -> 行政区筛选 -> 源影像上图 -> 提交预处理 -> 提交水体提取 -> 水体结果上图。
2. 洪涝检测:
输入灾害时间 + 灾害位置 -> 推荐配对 -> 预览覆盖 -> 提交洪涝检测 -> 分类图上图。
3. 套合分析:
选择 DONE 洪涝结果 -> 运行套合分析 -> 加载洪涝矢量 -> 查看灾害点、DInSAR、行政区统计。
4. 产品:
DONE 洪涝结果 -> 生成产品 -> 产品 tab 查看 -> 读取 manifest。
```
## 2026-05-15 第四轮:GeoTIFF 化技术决策
本轮根据新的产品判断调整洪涝模块技术路线:不再把 ENVI/SARscape
作为洪涝算法主线,后续水体提取、洪涝检测、套合分析全部运行在标准
GeoTIFF 层。原始 SAR 到 GeoTIFF 的部分做成可插拔前处理器,优先评估
GAMMA,GAMMA 覆盖不了的传感器使用 GF3_GDAL、SNAP、ISCE 或 external
处理器兜底。
### 已完成
```text
1. 新增 docs/FLOOD_GEOTIFF_GAMMA_PREPROCESS_DESIGN_20260515.md。
- 总结当前洪涝模块仍绑定 ENVI/SARscape 的真实状态。
- 梳理当前 GAMMA/PyINT 在项目中的能力和边界。
- 明确 GAMMA 适合作为 TIF 前处理候选,但不能把 D-InSAR pair profile
直接复用为洪涝单景预处理。
- 定义 analysis_ready.tif / SarAnalysisScene 数据契约。
- 给出 Python GeoTIFF 洪涝检测和 GAMMA 单景前处理分阶段方案。
2. 更新 docs/INDEX.md。
- 将 GeoTIFF 化设计列入当前执行中的核心设计。
- 标明 2026-05-14 旧洪涝设计中的 ENVI/SARscape 主线表述已被新设计取代。
```
### 当前判断
```text
1. 洪涝算法应先从 SARscape 黑盒迁出,改成 pre/post GeoTIFF 上的 Python 算法。
2. GAMMA 适合优先做 LT-1/Sentinel-1 的 analysis_ready.tif 生产器。
3. 当前 GAMMA 接入已有 geocode/data2geotiff 能力,但缺少独立单景预处理服务。
4. GF3 已有 Python/GDAL L1A->L2 路线,不应强行改走 GAMMA。
5. 另一台服务器部署时,Git 能提供平台代码和算法;GAMMA 本体、DEM、轨道池、
conda/WSL 运行时仍是外部部署前提。
```
+4 -4
View File
@@ -68,9 +68,8 @@ This is a workspace group, not a multi-tab planning tree.
生产管理
└─ 生产管理 (`production_management`)
├─ D-InSAR运行 (`dinsar_runs`)
├─ 时序InSAR运行 (`timeseries_runs`)
├─ D-InSAR产物 (`dinsar_products`)
└─ 时序InSAR产物 (`timeseries_products`)
├─ SBAS-InSAR Production (`sbas_insar_production`)
└─ D-InSAR产物 (`dinsar_products`)
```
Notes:
@@ -78,7 +77,8 @@ Notes:
- The left navigation contains only one tab for this group: `production_management`.
- Internal workspace views are controlled by `PRODUCTION_WORKSPACE_VIEWS`.
- Route alias mapping is controlled by `PRODUCTION_WORKSPACE_ENTRY_TO_VIEW`.
- Legacy route tabs such as `dinsar_production` and `ps_products` map into this workspace and should not be treated as standalone left-nav entries.
- Legacy route tabs such as `dinsar_production`, `ps_production`, and `ps_products` map into this workspace and should not be treated as standalone left-nav entries.
- The old ISCE2/MintPy `timeseries_runs` and `timeseries_products` workspace views are deprecated and hidden; SBAS production is handled by the Gamma `sbas_insar_production` view.
### 3.3 InSAR形变分析
@@ -0,0 +1,551 @@
# Gamma IPTA LT1 SBAS Trial Runbook
Date: 2026-05-18
## Goal
Validate whether the current local LT1 data pool and WSL Gamma installation can produce one usable SBAS/IPTA trial result with the official Gamma toolchain.
This is not yet a production integration design. The immediate goal is to run one conservative stack, inspect failures and product quality, then decide what should be productized in the system.
Actual trial root used in this repository:
```text
D:\Code\Insar_management_system_v2\backend\runtime\gamma_ipta_trials\lt1b_r114_e1312_n438_20240516_20251002
```
## Current Environment
- WSL distro configured by the project: `Ubuntu-24.04`.
- Gamma install found in WSL: `/usr/local/GAMMA_SOFTWARE-20240627`.
- Installed package name present on disk: `GAMMA_SOFTWARE-20240627_MSP_ISP_DIFF_IPTA.linux64_ubuntu2404.tar.gz`.
- Installed Gamma modules include `MSP`, `ISP`, `DIFF`, `DISP`, and `IPTA`.
- `GEO` is not a separate directory in this install, but DIFF contains the relevant geocoding tools, including `gc_map`, `geocode`, and `geocode_back`.
- Project Gamma environment script: `deploy/wsl/profiles/gamma_env.sh`.
- Project WSL Python: `/home/administrator/miniconda3/envs/insar_wsl_v1/bin/python`.
Important command checks already performed:
- `IPTA/bin/ts_rate` runs and prints usage. No license-denied error observed.
- `IPTA/bin/multi_def_pt` runs and prints usage. No license-denied error observed.
- `ISP/bin/par_LT1_SLC` exists and prints LT1 SLC conversion usage.
- `ISP/scripts/LT1_precision_orbit.py` runs with the project conda Python and prints usage.
## Data Pool Findings
Configured LT1 source pool:
- `D:\LuTan1_Image_Pool`
Configured LT1 precise orbit pool:
- `D:\orbit_pools\envi\LT1A`
- `D:\orbit_pools\envi\LT1B`
High-level inventory:
- LT1 scene directories found: `1500`.
- LT1A orbit TXT files found: `896`, spanning `20230508` to `20251219`.
- LT1B orbit TXT files found: `858`, spanning `20230510` to `20251219`.
Metadata caveat:
- `*.meta.xml` and `*_Check.xml` are not safe to parse as whole XML documents in PowerShell because many files contain malformed Chinese text near the tail, for example bad `usePreciseOrbit` closing text.
- The needed `productInfo` block is structurally valid. For stack discovery, parse only `<productInfo>...</productInfo>`.
- Gamma `par_LT1_SLC` should still use the original `.meta.xml`; do not rewrite source metadata unless a Gamma run proves the malformed tail is a blocker.
## Candidate Stack
First trial should use a narrow single-center stack, not the broad system time-series grouping.
Recommended trial stack:
- Satellite: `LT1B`
- Relative orbit: `114`
- Direction: `DESCENDING`
- Imaging mode: `STRIP1`
- Polarization: `HH`
- Approximate center: `E131.2 / N43.8`
- Scene count at this center: `7`
- Scenes with precise orbit TXT currently present: `5`
Use the 5 scenes with available precise orbit first:
| Date | Orbit TXT | Scene |
| --- | --- | --- |
| 20240516 | yes | `LT1B_MONO_SYC_STRIP1_012047_E131.2_N43.8_20240516_SLC_HH_S2A_0000399289` |
| 20240711 | yes | `LT1B_MONO_SYC_STRIP1_012880_E131.2_N43.8_20240711_SLC_HH_S2A_0000450956` |
| 20240905 | yes | `LT1B_MONO_SYC_STRIP1_013713_E131.2_N43.8_20240905_SLC_HH_S2A_0000501650` |
| 20250417 | yes | `LT1B_MONO_SYC_STRIP1_017045_E131.2_N43.8_20250417_SLC_HH_S2A_0000713375` |
| 20251002 | yes | `LT1B_MONO_SYC_STRIP1_019544_E131.2_N43.8_20251002_SLC_HH_S2A_0000891257` |
Do not include these two in the first run unless the missing orbits are added:
| Date | Orbit TXT | Scene |
| --- | --- | --- |
| 20250612 | no | `LT1B_MONO_SYC_STRIP1_017878_E131.2_N43.8_20250612_SLC_HH_S2A_0000772122` |
| 20250807 | no | `LT1B_MONO_SYC_STRIP1_018711_E131.2_N43.8_20250807_SLC_HH_S2A_0000831367` |
Common bounding box across the broader 13-scene `LT1B relOrbit 114 / E131-N44` candidate:
- lon: `130.8615 .. 131.1638`
- lat: `43.7127 .. 44.0987`
For the narrow `E131.2/N43.8` trial stack, overlap is visually/metadata-wise much tighter:
- Each scene center is around `131.20E, 43.79N`.
- Each scene bbox is roughly `130.81..131.62E`, `43.48..44.10N`.
Secondary candidate if the first stack fails:
- `LT1B relOrbit 114 DESCENDING STRIP1 HH`, center `E130.8/N43.9`.
- 5 dates, 4 with orbit: `20250425`, `20250620`, `20250815`, `20251010`.
- One scene is `MONO_MH1` while the others are `MONO_SYC`; keep it as secondary, not first choice.
## Current System Pairing Limitations
The current time-series stack selection is useful for broad discovery but is too coarse for Gamma IPTA production.
Observed code behavior:
- LT1A/LT1B are normalized into the same satellite family `LT1`.
- The compatibility key only uses direction, satellite family, imaging mode, and polarization.
- Relative orbit, absolute track family, scene center/strip identity, receiving station, and detailed LT1 product variant are not hard grouping keys.
- The stable-stack selector tries to recover by common AOI overlap and pairwise network connectivity.
- The SBAS network selector uses time-baseline, center-distance and overlap thresholds, but does not use Gamma-derived perpendicular baseline at planning time.
- Time-series processors currently accepted by the service are only `isce2_stack_mintpy` and `sarscape_sbas`; there is no `gamma_ipta_sbas` processor code yet.
For the Gamma IPTA trial, do not rely on the current automatic PS stack plan as the source of truth. Use a manually audited stack manifest first.
## Trial Run Checklist
### 1. Create Isolated Work Directory
Actual root:
```text
D:\Code\Insar_management_system_v2\backend\runtime\gamma_ipta_trials\lt1b_r114_e1312_n438_20240516_20251002
```
Keep these subdirectories:
```text
input\scenes
input\orbits
gamma\slc
gamma\mli
gamma\diff
gamma\ipta
logs
publish
```
For the first trial, prefer symlinks or a manifest that points to source scenes. Avoid duplicating large TIFF files unless Gamma scripts require local flat layout.
### 2. Build Scene Manifest
For each selected scene, record:
- scene directory
- `.tiff`
- `.meta.xml`
- precise orbit TXT
- date
- relative orbit
- direction
- mode
- polarization
- center lon/lat
- bbox
This manifest becomes the hand-audited truth for the trial.
### 3. Convert LT1 Products To Gamma SLC
For each selected scene:
```bash
source /mnt/d/Code/Insar_management_system_v2/deploy/wsl/profiles/gamma_env.sh
par_LT1_SLC <scene.tiff> <scene.meta.xml> <yyyymmdd>.slc.par <yyyymmdd>.slc
```
Then apply precise orbit:
```bash
/home/administrator/miniconda3/envs/insar_wsl_v1/bin/python \
/usr/local/GAMMA_SOFTWARE-20240627/ISP/scripts/LT1_precision_orbit.py \
<yyyymmdd>.slc.par <orbit_txt_or_orbit_folder>
```
### 4. Build SLC/MLI Tables
Create Gamma tables for the stack:
```text
SLC_tab
RMLI_tab
```
Use multilook settings conservative enough for a first run. The goal is robustness and fast feedback, not final product resolution.
### 5. Baseline And Pair Network
Use Gamma baseline tools first, not the system's center-distance approximation:
- `base_init`
- `base_perp`
- IPTA baseline tools such as `base_orbit_pt`, `base_par_pt`, `base_ls_pt` as needed by the official IPTA path.
For the first 5-scene stack, start with a simple connected small-baseline network:
- adjacent pairs by time
- add one or two skip pairs only if coherence and baseline look acceptable
Expected adjacent temporal intervals:
- `20240516 -> 20240711`: 56 days
- `20240711 -> 20240905`: 56 days
- `20240905 -> 20250417`: 224 days
- `20250417 -> 20251002`: 168 days
The large seasonal gaps are acceptable for a trial only if perpendicular baseline and coherence are reasonable. If they are poor, switch to a denser 2025-only or 2024-only local test, even with fewer dates.
### 6. Differential Interferograms
Use official Gamma DIFF commands/scripts for:
- coregistration
- interferogram generation
- simulated topographic phase
- differential phase
- filtering
- coherence
- unwrapping if needed by the chosen IPTA path
Do not implement custom SBAS inversion in the management system.
### 7. IPTA Processing
Use Gamma IPTA commands for point/stack time-series processing. Confirm exact command sequence against the installed:
```text
/usr/local/GAMMA_SOFTWARE-20240627/IPTA/html/IPTA_users_guide.pdf
```
Commands observed in the local IPTA module include:
- `multi_def_pt`
- `ts_rate`
- `ts_rate_pt`
- `base_ls_pt`
- `base_par_pt`
- `ph_base_pt`
- `atm_mod_pt`
- `pt2geo`
- `dis_ipta`
### 8. Review Outputs
Minimum acceptance checks for the first run:
- every selected scene converts to SLC
- precise orbit update succeeds for every SLC
- all intended pairs generate interferograms
- coherence is not uniformly poor
- unwrapping or IPTA point solution is not globally unstable
- one geocoded velocity or displacement-rate raster/vector product can be inspected
- logs and command manifests are complete enough to reproduce the run
## Trial Progress On 2026-05-18
### Files Created For This Trial
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/input/scene_manifest.json`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/01_prepare_slc.sh`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/02_mli_and_baseline.sh`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/03_coreg_one_pair.sh`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/04_cc_stats.py`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/05_coreg_adjacent_pairs.sh`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/06_prepare_rdc_dem.sh`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/07_coreg_common_ref_stack.sh`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/08_diff_unwrap_common_ref.sh`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/09_mb_ts_rate.sh`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/10_float_stats.py`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/11_make_timeseries_previews.sh`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/12_geocode_export_timeseries.sh`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/13_phase_to_los.py`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/14_build_trial_summary.py`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/15_make_los_velocity_maps.sh`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/16_plot_monitor_point_timeseries.py`
- `backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002/scripts/17_make_geocoded_web_previews.sh`
### Completed
- Created a hand-audited 5-scene LT1B manifest for `relOrbit 114 / DESCENDING / STRIP1 / HH / E131.2 N43.8`.
- Converted all 5 LT1 TIFF products with Gamma `par_LT1_SLC`.
- Applied precise orbit updates with Gamma `LT1_precision_orbit.py`.
- Built `gamma/slc/SLC_tab`.
- Built 8x8 multilooked MLI products with Gamma `multi_look`.
- Built `gamma/mli/RMLI_tab`.
- Generated all-pair and adjacent-pair baseline tables with Gamma `base_calc`.
- Ran adjacent-pair coregistration for all 4 selected adjacent pairs using Gamma `SLC_coreg.py`.
- Generated 4 interferograms and coherence products using Gamma `create_offset`, `SLC_intf`, and `cc_wave`.
- Generated browse previews using Gamma `rasmph` and `raspwr`.
- Computed coherence statistics for each adjacent pair.
- Reused the existing Copernicus30 DEM cache covering `E131.2/N43.8`.
- Generated a reference-geometry RDC DEM for `20240905` using Gamma `gc_map1`, `geocode`, and `gc_map_fine`.
- Built a common-reference RSLC/RMLI stack in `20240905` geometry.
- Generated common-reference differential interferograms with Gamma `phase_sim_orb` and `SLC_diff_intf`.
- Filtered common-reference differential interferograms with Gamma `adf`.
- Unwrapped common-reference differential interferograms with Gamma `mcf`.
- Ran Gamma IPTA `mb` to solve the image-based phase time-series.
- Ran Gamma IPTA `ts_rate` to estimate a linear phase-rate map.
- Generated Gamma preview rasters for `ts_rate`, `sigma_rate`, and `hgt_correction`.
- Exported phase-rate, sigma, height-correction, and LOS-rate rasters to EPSG:4326 GeoTIFF.
- Generated explicit LOS velocity maps in `mm/year` for both away-from-radar-positive and toward-radar-positive conventions.
- Generated one example monitoring-point LOS displacement curve as PNG, CSV, and metadata JSON.
- Generated north-up WGS84 web preview PNGs from the geocoded LOS GeoTIFF products.
### Key Outputs
- SLC stack: `gamma/slc/SLC_tab`
- MLI stack: `gamma/mli/RMLI_tab`
- All-pair baseline table: `gamma/diff/bperp_all_pairs.txt`
- Adjacent-pair table: `gamma/diff/itab_adjacent`
- Pair-specific RSLC products: `gamma/rslc/*_to_*.rslc`
- Pair-specific quality reports: `gamma/rslc/*_to_*.rslc.coreg_quality`
- Adjacent interferograms: `gamma/int/*.int`
- Adjacent coherence rasters: `gamma/int/*.cc`
- Adjacent interferogram previews: `gamma/int/*.int.bmp`
- Adjacent coherence previews: `gamma/int/*.cc.bmp`
- Coherence statistics: `logs/*_cc_stats.txt`
- Reference RDC DEM: `gamma/dem/20240905_8rlks.rdc.dem`
- Common-reference stack tables: `gamma/common_20240905/SLC_tab`, `gamma/common_20240905/RMLI_tab`
- Common-reference differential stack: `gamma/common_20240905/diff/*/*_8rlks.diff_filt.unw`
- Gamma `mb` time-series list: `gamma/common_20240905/timeseries/diff_ts.tab`
- Gamma `mb` phase time-series: `gamma/common_20240905/timeseries/diff_ts_*.diff`
- Gamma `mb` residual sigma: `gamma/common_20240905/timeseries/sigma_ts`
- Gamma `mb` height correction: `gamma/common_20240905/timeseries/hgt_correction`
- Gamma `ts_rate` output: `gamma/common_20240905/timeseries/ts_rate`
- Gamma `ts_rate` sigma output: `gamma/common_20240905/timeseries/sigma_rate`
- Preview rasters: `gamma/common_20240905/timeseries/*.bmp`
- Geocoded GeoTIFF exports: `publish/geotiff/*.tif`
- Explicit LOS velocity previews: `publish/geotiff/los_rate_toward_mm_per_year.bmp`, `publish/geotiff/los_rate_away_mm_per_year.bmp`
- Geocoded web previews: `publish/geotiff/los_rate_toward_mm_per_year.geo_preview.png`, `publish/geotiff/los_sigma_mm_per_year.geo_preview.png`
- Monitoring-point curve: `publish/monitor_points/auto_low_sigma_high_rate_timeseries.png`
- Monitoring-point values: `publish/monitor_points/auto_low_sigma_high_rate_timeseries.csv`
- Trial summary: `publish/trial_summary.json`
### Baseline Notes
`base_calc` generated 10 all-pair entries. The adjacent temporal network is:
| Pair | Delta days | Bperp from `base_calc` |
| --- | ---: | ---: |
| 20240516 -> 20240711 | 56 | 646.09670 |
| 20240711 -> 20240905 | 56 | -236.76960 |
| 20240905 -> 20250417 | 224 | 249.35880 |
| 20250417 -> 20251002 | 168 | 59.18150 |
The first validated pair was `20240711 -> 20240905` because it has a 56-day interval and moderate perpendicular baseline in this stack.
### Adjacent-Pair Quality
Final `SLC_coreg.py` quality-test summaries:
| Pair | Accepted offsets | Final std range | Final std azimuth |
| --- | ---: | ---: | ---: |
| 20240516 -> 20240711 | 2476 / 2688 | 0.0861 | 0.1120 |
| 20240711 -> 20240905 | 2483 / 2656 | 0.0370 | 0.1245 |
| 20240905 -> 20250417 | 2322 / 2688 | 0.0487 | 0.0617 |
| 20250417 -> 20251002 | 2274 / 2688 | 0.0304 | 0.0533 |
Coherence statistics from Gamma big-endian float files:
| Pair | valid `[0,1]` | p25 | median | p75 | p99 | Comment |
| --- | ---: | ---: | ---: | ---: | ---: | --- |
| 20240516 -> 20240711 | 100% | 0.127489 | 0.212285 | 0.323520 | 0.649398 | Weakest pair; high Bperp and low coherence |
| 20240711 -> 20240905 | 100% | 0.629307 | 0.733220 | 0.802884 | 0.909973 | Strong pair |
| 20240905 -> 20250417 | 100% | 0.339094 | 0.485239 | 0.612798 | 0.833928 | Usable trial pair |
| 20250417 -> 20251002 | 100% | 0.394165 | 0.582921 | 0.734878 | 0.932300 | Usable trial pair |
All adjacent pairs generated expected Gamma products. The full 5-scene chain can continue, but the first pair is a quality risk for unwrapping/IPTA. A more conservative follow-up is to run the 4-scene sub-stack from `20240711` to `20251002`, or keep the 5-scene stack but down-weight or exclude `20240516 -> 20240711` if later unwrapping/IPTA residuals are poor.
Implementation note:
- `SLC_coreg.py` writes the refined offset parameter file to a secondary-date path such as `gamma/slc/20240711.slc.off`.
- Trial scripts copy that file to pair-specific paths such as `gamma/rslc/20240711_to_20240905.rslc.off`.
- Future skip-pair or non-adjacent networks must use pair-specific offset files to avoid accidental reuse after the same secondary scene is coregistered to another reference.
### Common-Reference Time-Series Trial
The image-based Gamma IPTA path was also run using a common `20240905` reference geometry. This is closer to the official `mb -> ts_rate` time-series chain than the first adjacent-pair wrapped interferogram check.
Common-reference inputs and products:
- reference geometry: `20240905`
- DEM source: existing Copernicus30 cache under `backend/runtime/pyint_dem_cache`
- RDC DEM: `gamma/dem/20240905_8rlks.rdc.dem`
- common-reference SLC table: `gamma/common_20240905/SLC_tab`
- common-reference MLI table: `gamma/common_20240905/RMLI_tab`
- common-reference differential ITAB:
```text
1 3 1 1
2 3 2 1
3 4 3 1
3 5 4 1
```
Filtered differential coherence statistics:
| Pair | valid `[0,1]` | p25 | median | p75 | p99 |
| --- | ---: | ---: | ---: | ---: | ---: |
| 20240516 -> 20240905 | 100% | 0.497369 | 0.884586 | 0.945308 | 0.980583 |
| 20240711 -> 20240905 | 100% | 0.919149 | 0.968188 | 0.979449 | 0.990185 |
| 20240905 -> 20250417 | 100% | 0.366279 | 0.790519 | 0.909991 | 0.978094 |
| 20240905 -> 20251002 | 100% | 0.193104 | 0.815562 | 0.917209 | 0.972355 |
Gamma `mb` outputs:
- `gamma/common_20240905/timeseries/diff_ts_001.diff` through `diff_ts_005.diff`
- `gamma/common_20240905/timeseries/diff_ts.tab`
- `gamma/common_20240905/timeseries/itab_ts`
- `gamma/common_20240905/timeseries/sigma_ts`
- `gamma/common_20240905/timeseries/hgt_correction`
Gamma `ts_rate` outputs:
- `gamma/common_20240905/timeseries/ts_rate`
- `gamma/common_20240905/timeseries/ts_const`
- `gamma/common_20240905/timeseries/sigma_rate`
- `gamma/common_20240905/timeseries/ts_rate.bmp`
- `gamma/common_20240905/timeseries/sigma_rate.bmp`
- `gamma/common_20240905/timeseries/hgt_correction.bmp`
GeoTIFF exports:
- `publish/geotiff/ts_rate_rad_per_year.tif`
- `publish/geotiff/sigma_rate_rad_per_year.tif`
- `publish/geotiff/sigma_ts_rad.tif`
- `publish/geotiff/hgt_correction_m.tif`
- `publish/geotiff/los_rate_m_per_year.tif`
- `publish/geotiff/los_sigma_m_per_year.tif`
- `publish/geotiff/los_rate_away_mm_per_year.tif`
- `publish/geotiff/los_rate_toward_mm_per_year.tif`
- `publish/geotiff/los_sigma_mm_per_year.tif`
- `publish/geotiff/los_rate_toward_mm_per_year.geo_preview.png`
- `publish/geotiff/los_sigma_mm_per_year.geo_preview.png`
- `publish/trial_summary.json`
Float output statistics using Gamma big-endian float:
| File | Non-zero pixels | p25 | median | p75 | p99 |
| --- | ---: | ---: | ---: | ---: | ---: |
| `ts_rate` | 8,868,956 | -1.663257 | -0.271621 | 0.869116 | 2.787572 |
| `sigma_rate` | 8,868,956 | 0.396037 | 0.677578 | 1.010539 | 2.298520 |
| `sigma_ts` | 8,892,237 | 0.000704 | 0.001557 | 0.002535 | 0.518188 |
| `hgt_correction` | 8,892,250 | -16.989384 | 7.596325 | 36.411520 | 98.636933 |
Explicit LOS velocity output statistics in `mm/year`:
| File | Non-zero pixels | p01 | p25 | median | p75 | p99 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: |
| `los_rate_toward_mm_per_year.rdc` | 8,868,956 | -52.779640 | -16.455760 | 5.142855 | 31.491957 | 84.590944 |
| `los_sigma_mm_per_year.rdc` | 8,868,956 | 0.530715 | 7.498536 | 12.829198 | 19.133450 | 43.519972 |
This is a successful first local Gamma official-chain time-series trial. It is still a technical validation run, not a production-grade SBAS product: the stack has only 5 dates, the network is minimal, and reference region and unwrapping masks were conservative defaults.
The exported GeoTIFFs are EPSG:4326, `2222 x 2237`, Float32, LZW-compressed Cloud Optimized GeoTIFFs with `NoData=0`.
The `*.bmp` files generated by Gamma `rasdt_pwr` are RDC processing-geometry browse images. They are useful for quick processing QA but are not map products and should not be used as the default UI map preview. The UI/default web preview should use the `*.geo_preview.png` files generated from the EPSG:4326 GeoTIFF products.
### LOS Sign Convention
Gamma `ts_rate` is a phase-rate raster in `rad/year`. Converting phase rate to LOS displacement rate requires a sign convention:
- `los_rate_away_mm_per_year = phase_rate * wavelength / (4*pi) * 1000`
- `los_rate_toward_mm_per_year = -phase_rate * wavelength / (4*pi) * 1000`
Gamma `dispmap` documents two conventions:
- `sflg=0`, the default: motion away from radar is negative, so motion toward radar is positive; deformation and unwrapped phase have opposite signs.
- `sflg=1`: motion away from radar is positive; deformation and unwrapped phase have the same sign.
For system productization, use explicit names and prefer `los_rate_toward_mm_per_year` as the default display product because it matches Gamma `dispmap` default `sflg=0`. Keep the away-positive version available when another downstream convention requires direct phase-sign products.
### Monitoring Point Curve
An example monitoring point was selected automatically from low-sigma, high-rate, non-edge pixels:
- radar pixel: range `336`, azimuth `2290`
- approximate lon/lat: `131.4340324903`, `43.8008322757`
- reference date in the plotted time series: `20240711`
- LOS convention: toward radar positive, away from radar negative
- fitted LOS velocity: `50.1085 mm/year`
- fitted LOS velocity sigma: `0.0063 mm/year`
Generated outputs:
- `publish/monitor_points/auto_low_sigma_high_rate_timeseries.png`
- `publish/monitor_points/auto_low_sigma_high_rate_timeseries.csv`
- `publish/monitor_points/auto_low_sigma_high_rate_metadata.json`
The plotted values are:
| Date | LOS displacement, toward-positive mm |
| --- | ---: |
| 20240516 | -7.673957 |
| 20240711 | 0.000000 |
| 20240905 | 7.673922 |
| 20250417 | 38.412669 |
| 20251002 | 61.464305 |
This is a single example point only. It is not a validated monitoring-point network and should not be interpreted as a representative area-wide deformation curve. The automatic selection favors a non-edge pixel with relatively high absolute velocity and low fitted sigma so the curve is visually inspectable. Production monitoring points need one of these inputs:
- user-clicked map lon/lat
- imported engineering monitoring-point layer
- a configured regular grid or point-of-interest set
- a quality-filtered automatic point sampler with spacing, coherence/sigma thresholds, and manual review
Until that is implemented, the single curve is a capability demonstration and should be labeled as such in the UI.
### Current Open Items
- Review the `ts_rate.bmp`, `sigma_rate.bmp`, `hgt_correction.bmp`, LOS velocity BMPs, monitoring-point PNG, and exported GeoTIFFs visually in GIS.
- Tune reference region, coherence thresholds, and pair network before treating the result as production.
- Decide whether to keep the weak/long 2025 pair, add skip-pairs, or use a denser data sequence when more LT1 precise orbits are available.
- Keep system integration as orchestration around Gamma commands; do not implement custom SBAS inversion in application code.
## Productization Decisions After Trial
If the 5-scene trial succeeds, add a new managed processor instead of bending existing ISCE/SARscape flows:
```text
processor_code = gamma_ipta_sbas
engine_code = gamma
workflow = gamma_ipta_sbas
```
Required system changes:
- Add a Gamma IPTA stack manifest builder.
- Add LT1-specific hard grouping keys:
- satellite platform, not only family, unless cross-satellite LT1A/LT1B is explicitly validated
- relative orbit
- orbit direction
- imaging mode
- polarization
- scene strip/center bucket
- product variant or station/submode where it affects compatibility
- Add a Gamma baseline audit step before final pair network selection.
- Persist selected Gamma pair network separately from the coarse planning graph.
- Keep system code as orchestration only; Gamma remains the processing authority.
## Current Recommendation
Use the manual audited `LT1B relOrbit 114 DESCENDING STRIP1 HH / E131.2 N43.8` stack and the trial scripts as the reference path for productization.
Do not start Gamma IPTA production from the current automatic time-series plan. It can be used for discovery, but production stack selection needs the hard grouping keys and Gamma baseline audit described above.
For the next engineering step, add a managed `gamma_ipta_sbas` processor that orchestrates the Gamma commands rather than reimplementing SBAS inversion in application code.
+15 -8
View File
@@ -36,8 +36,11 @@
- [PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md](PRODUCTION_RESULTS_MULTI_ENGINE_DESIGN_20260423.md)
多引擎结果目录、发布结构和 catalog 治理设计。
- [FLOOD_GEOTIFF_GAMMA_PREPROCESS_DESIGN_20260515.md](FLOOD_GEOTIFF_GAMMA_PREPROCESS_DESIGN_20260515.md)
洪涝模块 GeoTIFF 化与 GAMMA 前处理评估,定义“原始数据到分析级 TIF 由可插拔处理器完成,水体/洪涝算法只运行在 GeoTIFF 层”的当前技术决策。
- [FLOOD_DISASTER_ANALYSIS_SYSTEM_DESIGN_20260514.md](FLOOD_DISASTER_ANALYSIS_SYSTEM_DESIGN_20260514.md)
洪涝灾害分析独立系统设计,定义多源 SAR 数据、ENVI/SARscape 洪涝流程、标准产品包和矢量套合分析边界。
洪涝灾害分析独立系统早期设计,保留工作台、产品包和矢量套合分析边界;其中 ENVI/SARscape 作为洪涝主线的表述已被 2026-05-15 GeoTIFF 化设计取代。
- [WSL_RUNTIME_REFACTOR_DESIGN_20260422.md](WSL_RUNTIME_REFACTOR_DESIGN_20260422.md)
WSL 共享运行时和 Broker 设计。
@@ -50,15 +53,18 @@
## 3. 时序 InSAR / SBAS
- [ISCE2_SBAS_TIMESERIES_DESIGN.md](ISCE2_SBAS_TIMESERIES_DESIGN.md)
- [ISCE2_SBAS_PRODUCT_SPEC.md](ISCE2_SBAS_PRODUCT_SPEC.md)
- [ISCE2_SBAS_ENGINEERING_DESIGN_20260428.md](ISCE2_SBAS_ENGINEERING_DESIGN_20260428.md)
Current-phase engineering design for the managed ISCE2 + MintPy SBAS route.
- [SBAS_INSAR_PRODUCTION_PIPELINE_DESIGN_20260519.md](SBAS_INSAR_PRODUCTION_PIPELINE_DESIGN_20260519.md)
Gamma/LT1 SBAS-InSAR 独立生产页面与统一流水线收口设计。
- [TIMESERIES_LEGACY_DEPRECATION_20260521.md](TIMESERIES_LEGACY_DEPRECATION_20260521.md)
旧 ISCE2/MintPy 时序生产链路停用记录,定义当前入口隐藏、配置默认关闭和后续物理删除条件。
- [GAMMA_IPTA_LT1_SBAS_TRIAL_RUNBOOK_20260518.md](GAMMA_IPTA_LT1_SBAS_TRIAL_RUNBOOK_20260518.md)
Gamma 官方 DIFF + IPTA 路径处理 LT1 数据的本地试验记录、产物和符号约定。
说明:
- 当前前端顶级显示名已经统一为“时序 InSAR”。
- 当前默认接入仍然是 SBAS 路径,因此这两份文档仍然有效。
- 当前 SBAS-InSAR 生产主线是 Gamma 独立生产页面,不再使用旧 ISCE2/MintPy 时序生产页作为入口。
- 旧 `timeseries-production` 后端、旧前端面板和 `ps_timeseries_runs` 相关结构暂时保留兼容,但默认关闭。
- `ISCE2_SBAS_TIMESERIES_DESIGN.md`、`ISCE2_SBAS_PRODUCT_SPEC.md`、`ISCE2_SBAS_ENGINEERING_DESIGN_20260428.md` 仅作为历史参考,不再作为当前生产事实依据。
- `SBAS_*` 命名的一批旧文档已经归档,只保留历史追溯价值。
## 4. 配对与前端导航
@@ -130,4 +136,5 @@
- 已归档的 `项目汇报.md`
- 已归档的各类 `*_EXPERIMENT_*` / `*_PROGRESS_*` / `*_TODO_*`
最后更新:2026-05-12
最后更新:2026-05-15
最近修订:2026-05-21
@@ -0,0 +1,439 @@
# SBAS-InSAR Production Pipeline Design
Date: 2026-05-19
## Decision
SBAS-InSAR production becomes an independent production workflow and page. It must not depend on the existing coarse time-series pairing layer as its production authority.
The old time-series pairing code may remain temporarily for compatibility and candidate discovery, but the new SBAS-InSAR page and backend API should bypass it by default. Deletion should happen only after the new workflow can create, run, publish, and browse Gamma SBAS/IPTA products end to end.
The legacy ISCE2/MintPy time-series production chain is disabled by default. The `timeseries-production` backend code and old catalog pages may remain as compatibility code, but they are no longer exposed as production-management subpages. The active SBAS production route is `/api/sbas-insar-production` and the active UI view is `sbas_insar_production`.
## Scope
Initial production target:
- Sensor: LT1 SLC
- Engine: Gamma
- Workflow: Gamma DIFF + IPTA `mb` + `ts_rate`
- Processor code: `gamma_ipta_sbas`
- Default display product: LOS velocity toward radar positive
Out of scope for the first implementation slice:
- custom SBAS inversion in application code
- direct reuse of the current PS/time-series pair graph as final Gamma `itab`
- full automatic stack approval without a Gamma baseline and quality audit
- cross-satellite LT1A/LT1B stack mixing
## Product Contract
Every successful SBAS-InSAR production run should publish:
- `product_summary.json`
- `stack_manifest.json`
- `gamma_command_manifest.json`
- `pair_network.json`
- `quality_summary.json`
- `los_rate_toward_mm_per_year.tif`
- `los_rate_toward_mm_per_year.geo_preview.png`
- `los_rate_toward_mm_per_year.rdc_preview.bmp`
- `los_rate_away_mm_per_year.tif`
- `los_rate_away_mm_per_year.rdc_preview.bmp`
- `los_sigma_mm_per_year.tif`
- `los_sigma_mm_per_year.geo_preview.png`
- `los_sigma_mm_per_year.rdc_preview.bmp`
- `ts_rate_rad_per_year.tif`
- `sigma_rate_rad_per_year.tif`
- monitoring-point time-series `png/csv/json`
- raw logs for each Gamma stage
The current trial product remains the reference implementation:
```text
backend/runtime/gamma_ipta_trials/lt1b_r114_e1312_n438_20240516_20251002
```
Preview rule:
- UI default map previews must be rendered from geocoded EPSG:4326 GeoTIFFs.
- RDC/RMLI BMP browse images are processing QA artifacts only.
- Product names should make coordinate state explicit: `geo_preview` for map previews and `rdc_preview` for radar-geometry previews.
## LOS Sign Convention
Gamma `ts_rate` outputs phase rate in `rad/year`. The system must store both sign conventions explicitly:
```text
los_rate_away_mm_per_year = phase_rate * wavelength / (4*pi) * 1000
los_rate_toward_mm_per_year = -phase_rate * wavelength / (4*pi) * 1000
```
Default UI display:
```text
LOS toward radar positive
```
This matches Gamma `dispmap` default `sflg=0`: motion away from radar is negative, motion toward radar is positive.
## Page Design
Add a separate production view:
```text
Production Management
- D-InSAR Runs
- SBAS-InSAR Production
- D-InSAR Products
```
The SBAS page is operational, not a marketing landing page. First screen should show:
- runtime capability: Gamma install, WSL distro, workflow support
- available SBAS stacks or trial runs
- selected run summary
- LOS velocity geocoded preview
- product file list
- monitoring-point curve
- quality metrics
- stage checklist
The current old "time-series run" and "time-series products" views are hidden from the production workspace. Legacy route aliases such as `ps_production` and `ps_products` should redirect to the SBAS-InSAR production view rather than opening the old ISCE2/MintPy workflow.
## Backend API
Initial read-only API:
```text
GET /api/sbas-insar-production/capabilities
GET /api/sbas-insar-production/trial-runs
GET /api/sbas-insar-production/trial-runs/{trial_id}
GET /api/sbas-insar-production/trial-runs/{trial_id}/artifacts/{relative_path}
```
Stack discovery and hard-constraint audit API:
```text
POST /api/sbas-insar-production/stacks/discover
POST /api/sbas-insar-production/stacks/{stack_id}/audit
```
`stacks/discover` scans LT1 source roots directly and groups scenes by:
- platform, for example `LT1A` or `LT1B`
- satellite mode, for example `MONO`
- receiving station
- relative orbit
- orbit direction
- imaging mode
- polarization
- center bucket, for example `E131.2_N43.8`
It also checks LT1 precise orbit TXT availability against `PYINT_ORBIT_POOL_TXT` / `ORBIT_POOL_ENVI`.
`stacks/{stack_id}/audit` writes a reproducible manifest under:
```text
backend/runtime/sbas_insar_production/stack_manifests/{stack_id}/
```
The manifest status is `READY_FOR_GAMMA_BASELINE_AUDIT` only after hard grouping, minimum scene count, precise orbit availability, and an initial adjacent temporal network are satisfied. Gamma `base_calc` remains the next required audit before final `itab` approval.
Writable production planning API:
```text
POST /api/sbas-insar-production/stacks/{stack_id}/runs
GET /api/sbas-insar-production/runs
GET /api/sbas-insar-production/runs/{run_id}
GET /api/sbas-insar-production/runs/{run_id}/artifacts/{relative_path}
```
The current `runs` submission is a dry-run planning submission. It writes:
```text
backend/runtime/sbas_insar_production/runs/{run_id}/run_manifest.json
backend/runtime/sbas_insar_production/runs/{run_id}/stack_manifest.json
backend/runtime/sbas_insar_production/runs/{run_id}/pair_network.json
backend/runtime/sbas_insar_production/runs/{run_id}/gamma_command_manifest.json
backend/runtime/sbas_insar_production/runs/{run_id}/monitor_points.json
```
The created run status is:
```text
PLANNED_GAMMA_BASELINE_AUDIT
```
This is intentionally not a Gamma execution trigger yet. The next runnable slice should add:
```text
POST /api/sbas-insar-production/runs/{run_id}/baseline-audit
POST /api/sbas-insar-production/runs/{run_id}/itab-decision
POST /api/sbas-insar-production/runs/{run_id}/coregistration
POST /api/sbas-insar-production/runs/{run_id}/coregistration/jobs
POST /api/sbas-insar-production/runs/{run_id}/monitor-points
POST /api/sbas-insar-production/runs/{run_id}/retry-stage
```
`baseline-audit` currently supports:
- script-only mode: generate/reparse `scripts/01_baseline_audit.sh` and existing outputs
- execution mode: run Gamma `par_LT1_SLC`, `LT1_precision_orbit.py`, `multi_look`, and `base_calc`
- output parsing: write `baseline_audit_summary.json` and `pair_network_baseline_audit.json`
`itab-decision` is the current production gate:
- `approve`: copies Gamma `work/gamma/diff/itab_adjacent` to `work/gamma/diff/itab_approved`, writes `itab_decision.json`, moves the run to `ITAB_APPROVED`, and makes `coregistration` the next stage
- `reject`: writes `itab_decision.json`, moves the run to `ITAB_REJECTED`, and blocks further Gamma stages until the pair network is revised
`coregistration` supports script generation. It writes:
```text
backend/runtime/sbas_insar_production/runs/{run_id}/scripts/02_coreg_common_ref.sh
backend/runtime/sbas_insar_production/runs/{run_id}/coregistration_plan.json
```
The generated script consumes `work/gamma/diff/itab_approved` as the approval gate, uses the stack reference date as common geometry, and prepares Gamma `SLC_coreg.py` calls for every non-reference date.
`coregistration/jobs` submits the generated script to the existing `SystemTask` + `SystemJob` background queue as job type `SBAS_COREGISTRATION`. The job runs Gamma `SLC_coreg.py`, writes `coregistration_summary.json`, updates the run manifest to `COREGISTRATION_READY` or `COREGISTRATION_FAILED`, and advances the next stage to `rdc_dem` only when all expected RSLC/RMLI outputs and common tab files exist.
## Backend Services
First slice:
```text
sbas_insar_production_service.py
- discover local Gamma IPTA trial summaries
- normalize products and artifact URLs
- expose sign convention and product metadata
- serve safe artifacts from trial roots
```
Second slice:
```text
gamma_ipta_stack_planner.py
- hard group LT1 scenes by platform, relative orbit, direction, mode, polarization, center bucket
- require precise orbit availability
- emit stack_manifest.json
gamma_ipta_pair_planner.py
- build initial temporal network
- run Gamma baseline audit
- emit pair_network.json and Gamma itab
gamma_ipta_job_runner.py
- execute official Gamma commands stage by stage
- write command manifests and logs
sbas_insar_product_publisher.py
- publish GeoTIFF/BMP/CSV/JSON products
- register products into unified result catalog
```
## Future Database Model
Use unified pipeline tables rather than adding many one-off SBAS tables:
```text
pipeline_runs
pipeline_stages
pipeline_products
pipeline_quality_metrics
pipeline_logs
```
Minimum fields for `pipeline_runs`:
- `run_id`
- `workflow_code = sbas_insar`
- `processor_code = gamma_ipta_sbas`
- `engine_code = gamma`
- `status`
- `stack_manifest_path`
- `work_root`
- `publish_root`
- `created_by`
- `created_at`
- `started_at`
- `ended_at`
- `summary_json`
For the first slice, use filesystem discovery only. Do not add migrations until the run submission workflow is ready.
## Gamma Stage Contract
The managed runner should preserve the successful trial chain:
1. `par_LT1_SLC`
2. `LT1_precision_orbit.py`
3. `multi_look`
4. `base_calc`
5. `SLC_coreg.py`
6. `gc_map1` / `geocode` / `gc_map_fine`
7. `phase_sim_orb`
8. `SLC_diff_intf`
9. `adf`
10. `mcf`
11. `mb`
12. `ts_rate`
13. `geocode_back`
14. `data2geotiff`
15. LOS sign conversion and preview generation
16. monitoring-point time-series extraction
The application is an orchestrator. Gamma remains the processing authority.
## Migration Plan
Phase 1: read-only SBAS production page
- add design document
- add backend API for existing Gamma trial discovery
- add page entry and product preview
- keep old time-series page available as legacy
Phase 2: managed run submission
- add stack discovery and audit endpoints
- add planned-run submission endpoint
- write production run manifest, command manifest, and monitor-point config
- add Gamma runner skeleton
- queue job with stage updates
Phase 3: unified pipeline management
- add generic pipeline tables
- move Gamma SBAS run records into pipeline tables
- register products through the unified product catalog
Phase 4: remove old SBAS/time-series pairing dependency
- hide old SBAS entry completely
- keep any reusable discovery functions as internal utilities
- delete obsolete UI and API routes after dependency audit
## Acceptance Criteria For Phase 1
- SBAS-InSAR production appears as its own production workspace view.
- Existing Gamma IPTA trial can be listed from the backend API.
- The selected trial shows LOS velocity preview, sigma/GeoTIFF products, monitor-point curve, and quality summary.
- Artifact serving is constrained to the trial root.
- No existing D-InSAR, flood, or legacy time-series routes are broken.
## Implementation Progress On 2026-05-19
Implemented:
- read-only SBAS-InSAR production page
- trial product browser for the local Gamma IPTA validation run
- artifact API constrained to published trial outputs
- LT1 filesystem stack discovery independent of the old time-series pairing layer
- hard grouping by platform, satellite mode, receiving station, relative orbit, orbit direction, imaging mode, polarization, and center bucket
- precise orbit TXT availability check against the configured Gamma/PyINT orbit pool
- stack manifest and initial adjacent pair-network JSON generation
- geocoded web previews generated from `los_rate_toward_mm_per_year.tif` and `los_sigma_mm_per_year.tif`
- planned SBAS production run creation from a READY stack manifest
- filesystem production run browser and artifact download API
- Gamma stage plan manifest with execution disabled until baseline audit runner is attached
- monitoring-point config contract with explicit placeholder status for `auto_low_sigma_high_rate`
- Gamma baseline audit script generation and output parser
- baseline audit result display in the SBAS production page
- itab approval/rejection API and page controls
- common-reference coregistration script generation and page summary
- queued `SBAS_COREGISTRATION` background job submission through the existing task/job queue
- coregistration execution summary parser and manifest status update to `COREGISTRATION_READY` / `COREGISTRATION_FAILED`
Local verification:
- scanned `1500` LT1 scene directories from the local data pool
- found READY candidates with all required precise orbit TXT files
- generated one manifest at:
```text
backend/runtime/sbas_insar_production/stack_manifests/sbas_2e6301f64a10/20260519T122146Z_stack_manifest.json
```
- created one dry-run production plan at:
```text
backend/runtime/sbas_insar_production/runs/sbas_ab96afabead5/run_manifest.json
```
The dry-run production plan uses the local LT1B relOrbit `114` stack around `E129.2_N44.1`, with `7` scenes and `6` initial adjacent temporal pairs.
- baseline audit script:
```text
backend/runtime/sbas_insar_production/runs/sbas_ab96afabead5/scripts/01_baseline_audit.sh
```
- baseline audit summary:
```text
backend/runtime/sbas_insar_production/runs/sbas_ab96afabead5/baseline_audit_summary.json
```
The baseline audit ran Gamma 20240627 `par_LT1_SLC`, `LT1_precision_orbit.py`, `multi_look`, and `base_calc` against all 7 LT1B scenes. It completed with status `BASELINE_AUDIT_READY` after the outer terminal command timed out, because the WSL process continued to completion in the background.
Gamma `base_calc` adjacent-network result:
- all-pair count: `21`
- adjacent-pair count: `6`
- max absolute perpendicular baseline: `731.9957 m`
- max temporal gap: `224 days`
The current adjacent network is connected, but several Bperp values are large enough that a human baseline/quality review is still required before using this `itab` for the full SBAS inversion.
The current run has been approved for the next controlled trial step:
```text
status = ITAB_APPROVED
next_stage = coregistration
approved itab = backend/runtime/sbas_insar_production/runs/sbas_ab96afabead5/work/gamma/diff/itab_approved
decision record = backend/runtime/sbas_insar_production/runs/sbas_ab96afabead5/itab_decision.json
```
The common-reference co-registration script has been generated:
```text
backend/runtime/sbas_insar_production/runs/sbas_ab96afabead5/scripts/02_coreg_common_ref.sh
backend/runtime/sbas_insar_production/runs/sbas_ab96afabead5/coregistration_plan.json
```
Current status before executing the queued job:
```text
status = COREGISTRATION_SCRIPT_READY
next_stage = execute_coregistration
common reference date = 20241007
```
The actual `SLC_coreg.py` execution is now wired as a background job endpoint and page action, but has not been production-tested in this pass. It consumes `itab_approved`, not the pre-audit or unapproved pair plan.
Expected post-job outputs:
```text
backend/runtime/sbas_insar_production/runs/sbas_ab96afabead5/coregistration_summary.json
backend/runtime/sbas_insar_production/runs/sbas_ab96afabead5/work/gamma/common_20241007/SLC_tab
backend/runtime/sbas_insar_production/runs/sbas_ab96afabead5/work/gamma/common_20241007/RMLI_tab
backend/runtime/sbas_insar_production/runs/sbas_ab96afabead5/work/gamma/common_20241007/rslc/*.rslc
backend/runtime/sbas_insar_production/runs/sbas_ab96afabead5/work/gamma/common_20241007/rmli/*.mli
```
After the job succeeds:
```text
status = COREGISTRATION_READY
next_stage = rdc_dem
```
Open product-display decisions:
- the current Gamma `*.bmp` previews are RDC processing-geometry products; keep them visible only as QA artifacts
- the first UI map preview should use `los_rate_toward_mm_per_year.geo_preview.png`
- the first sigma preview should use `los_sigma_mm_per_year.geo_preview.png`
- the current monitoring-point curve is a single automatic sample point, not a monitoring network
- production monitoring curves need user-selected lon/lat points, imported monitoring points, or a quality-filtered automatic sampler before they can be treated as formal outputs
@@ -0,0 +1,184 @@
# Legacy Time-Series InSAR Deprecation Record
Date: 2026-05-21
## Background
The project previously kept an ISCE2 + MintPy time-series/SBAS production path under:
```text
experiments/isce2_sbas_timeseries
```
That experiment directory has been removed. The active SBAS-InSAR direction is now the independent Gamma production workflow documented in:
```text
docs/SBAS_INSAR_PRODUCTION_PIPELINE_DESIGN_20260519.md
docs/GAMMA_IPTA_LT1_SBAS_TRIAL_RUNBOOK_20260518.md
```
Before this cleanup, startup validation still generated warnings for missing legacy paths:
```text
TIMESERIES_EXPERIMENT_ROOT
TIMESERIES_STACK_PREP_SCRIPT
TIMESERIES_MATERIALIZE_SCRIPT
TIMESERIES_PREPARE_DEM_SCRIPT
TIMESERIES_STACK_RUNNER_SCRIPT
TIMESERIES_MINTPY_SBAS_SCRIPT
TIMESERIES_EXPORT_PUBLISH_SCRIPT
```
Those warnings were misleading because they referred to the abandoned ISCE2/MintPy line, not the current Gamma SBAS-InSAR production line.
## Decision
The ISCE2/MintPy time-series production chain is deprecated and disabled by default.
The current SBAS-InSAR production authority is:
```text
Frontend view: sbas_insar_production
Backend route: /api/sbas-insar-production
Engine: Gamma
Workflow: DIFF + IPTA SBAS
```
The legacy code is not physically deleted yet. It remains only as compatibility and historical-reference code until the Gamma SBAS workflow can be tested end to end and historical result access is confirmed.
## Changes Made
Backend configuration:
- `backend/app/config.py`
- `TIMESERIES_ENABLED` default changed from `true` to `false`.
- Legacy `TIMESERIES_*` experiment/script defaults are now populated only when `TIMESERIES_ENABLED=true`.
- `ensure_dirs()` no longer creates `TIMESERIES_WORK_ROOT` unless the legacy chain is explicitly enabled.
- Runtime validation now reports an info line instead of warning about missing legacy experiment scripts when the chain is disabled.
Environment example:
- `.env.example`
- `TIMESERIES_ENABLED=false`
- `TIMESERIES_EXPERIMENT_ROOT=` is blank.
- `TIMESERIES_DEFAULT_PROCESSOR_CODE=legacy_isce2_stack_mintpy`
Frontend production management:
- `frontend/src/config/appConstants.js`
- Removed production workspace views:
- `timeseries_runs`
- `timeseries_products`
- Legacy route aliases now map to the Gamma SBAS page:
- `ps_production -> sbas_insar_production`
- `ps_products -> sbas_insar_production`
- `frontend/src/ProductionWorkspace.jsx`
- Removed lazy imports and render branches for:
- `TimeseriesProductionPanel`
- production-management `PsinsarCatalogPanel`
- Updated production workspace text to describe Gamma SBAS as an independent entry.
- `frontend/src/components/app/AppSidePanel.jsx`
- Updated production-management description to state that the old ISCE2/MintPy time-series entry is disabled.
Documentation:
- `docs/SBAS_INSAR_PRODUCTION_PIPELINE_DESIGN_20260519.md`
- Added the legacy-chain deprecation decision.
- `docs/FRONTEND_NAVIGATION_ARCHITECTURE.md`
- Updated production-management internal views to:
- `dinsar_runs`
- `sbas_insar_production`
- `dinsar_products`
## Current Behavior
After backend restart, deployment validation should no longer warn about the removed `experiments/isce2_sbas_timeseries` path.
Expected validation line:
```text
[INFO] Legacy ISCE2/MintPy timeseries pipeline is disabled; current SBAS-InSAR production uses the Gamma /sbas-insar-production workflow.
```
The production-management page should show:
```text
D-InSAR 运行
SBAS-InSAR Production
D-InSAR 产物
```
It should no longer expose:
```text
时序InSAR 运行
时序InSAR 产物
```
## Verification
Completed on 2026-05-21:
- Frontend build passed:
```text
npm run build
```
- Runtime configuration check passed:
```text
scripts/check_runtime_config.py
```
Observed output included:
```text
[INFO] Legacy ISCE2/MintPy timeseries pipeline is disabled; current SBAS-InSAR production uses the Gamma /sbas-insar-production workflow.
[OK] Deployment configuration check passed.
```
- Backend config syntax was checked with Python AST parsing.
`python -m py_compile backend/app/config.py` was not used as the final check because Windows denied replacement of an existing `__pycache__` file. This was a local cache-permission issue, not a syntax failure.
## Retained Compatibility Code
The following code is intentionally retained for now:
```text
backend/app/routers/timeseries_production.py
backend/app/services/timeseries_service.py
frontend/src/TimeseriesProductionPanel.jsx
frontend/src/api/timeseriesProduction.js
frontend/src/components/PsinsarCatalogPanel.jsx
```
Database tables and historical product catalog structures such as `ps_timeseries_runs` are also retained.
`PsinsarCatalogPanel` may still be useful outside production management, especially for analysis/result browsing. Do not delete it until those usages are audited.
## Re-Enabling Legacy Chain
Re-enabling the legacy ISCE2/MintPy chain is not part of the current production plan.
If it must be revived for a controlled comparison, the operator must explicitly set:
```text
TIMESERIES_ENABLED=true
```
and provide valid values for all legacy experiment/script paths. The removed `experiments/isce2_sbas_timeseries` directory is no longer assumed to exist.
## Follow-Up Cleanup Criteria
Physical deletion of the legacy chain should wait until all of the following are true:
- Gamma SBAS production has completed an end-to-end run from stack discovery to published LOS velocity/sigma products.
- Historical `ps_timeseries_runs` and old time-series product records have a clear migration or read-only archival plan.
- Frontend navigation, route aliases, and analysis pages have been audited for remaining dependencies.
- Backend callers of `/api/timeseries-production` have either been removed or explicitly marked as legacy-only.
- Test coverage or manual regression notes confirm that D-InSAR production, SBAS production, product browsing, and task monitoring still work.
+97 -3
View File
@@ -282,6 +282,8 @@ function App() {
const aoeLayerRef = useRef(null);
const waterSceneLayersRef = useRef({});
const floodEventLayersRef = useRef({}); // { eventId: { pre, post, classified } }
const floodPairPreviewLayerRef = useRef(null);
const floodVectorLayersRef = useRef({});
const mapRegionLayerRef = useRef(null);
const prevLicenseOkRef = useRef(false);
const initializeAppDataRef = useRef(null);
@@ -327,6 +329,7 @@ function App() {
pairLayersRef: pairLayersRef.current,
aoeLayerRef: aoeLayerRef.current,
floodEventLayersRef: floodEventLayersRef.current,
floodVectorLayersRef: floodVectorLayersRef.current,
}), []);
const mapExport = useMapExport({ mapRef, getVisibleLayerRefs, addLog, language });
@@ -1468,8 +1471,11 @@ function App() {
const handleWaterSceneOnMap = (scene) => {
if (!mapRef.current || !scene.coverage_polygon) return;
if (waterSceneLayersRef.current[scene.id]) {
waterSceneLayersRef.current[scene.id].remove();
const layerKey = scene.radar_data_id ? `scene_${scene.id}` : `radar_${scene.id}`;
if (waterSceneLayersRef.current[layerKey]) {
waterSceneLayersRef.current[layerKey].remove();
delete waterSceneLayersRef.current[layerKey];
return false;
}
const latLngs = scene.coverage_polygon.map(p => [p[1], p[0]]);
const polygon = L.polygon(latLngs, {
@@ -1484,10 +1490,35 @@ function App() {
`${scene.satellite || ''} · ${scene.imaging_date || ''}<br>` +
`<span style="color:#888;font-size:11px">${(scene.geo_path || '').split(/[\\/]/).pop()}</span>`
);
waterSceneLayersRef.current[scene.id] = polygon;
waterSceneLayersRef.current[layerKey] = polygon;
polygon.addTo(mapRef.current);
mapRef.current.flyToBounds(L.latLngBounds(latLngs), { padding: [50, 50], maxZoom: 10 });
polygon.openPopup();
return true;
};
const handleFloodSourcePreviewOnMap = (item) => {
if (!mapRef.current || !item) return;
const enriched = {
...item,
displayName: item.displayName || item.file_path?.split(/[\\/]/).pop() || `${item.satellite || 'SAR'} #${item.id}`,
previewCacheKey: item.previewCacheKey || item.preview_cache_updated_at || `${Date.now()}-${item.id}`,
};
if (radarPreviewLayersRef.current[enriched.id]) {
updateRadarPreviewVisibility(enriched, false);
return false;
}
updateRadarPreviewVisibility(enriched, true);
if (
enriched.min_lat != null && enriched.max_lat != null
&& enriched.min_lon != null && enriched.max_lon != null
) {
mapRef.current.flyToBounds(
L.latLngBounds([[enriched.min_lat, enriched.min_lon], [enriched.max_lat, enriched.max_lon]]),
{ padding: [50, 50], maxZoom: 10 },
);
}
return true;
};
// 洪涝事件三图层叠加(灾前/灾后/分类结果),每层可独立开关
@@ -1534,6 +1565,66 @@ function App() {
else layers[key].remove();
};
const handleFloodPairPreviewOnMap = (pair) => {
if (!mapRef.current || !pair?.pre?.coverage_polygon || !pair?.post?.coverage_polygon) return;
if (floodPairPreviewLayerRef.current) {
floodPairPreviewLayerRef.current.remove();
floodPairPreviewLayerRef.current = null;
}
const makePolygon = (scene, color, label) => {
const latLngs = scene.coverage_polygon.map(p => [p[1], p[0]]);
const polygon = L.polygon(latLngs, {
color,
weight: 2,
fillColor: color,
fillOpacity: 0.14,
});
polygon.bindPopup(
`<strong>${escapeHtml(label)}</strong><br>` +
`场景 #${escapeHtml(scene.id)} · ${escapeHtml(scene.satellite || '')}<br>` +
`${escapeHtml(formatYmd(scene.imaging_date, language))} · ${escapeHtml(scene.polarization || '')}`
);
return polygon;
};
const prePolygon = makePolygon(pair.pre, '#2563eb', '灾前覆盖');
const postPolygon = makePolygon(pair.post, '#16a34a', '灾后覆盖');
const group = L.featureGroup([prePolygon, postPolygon]);
floodPairPreviewLayerRef.current = group;
group.addTo(mapRef.current);
mapRef.current.flyToBounds(group.getBounds(), { padding: [50, 50], maxZoom: 10 });
};
const handleFloodVectorOnMap = (impact) => {
if (!mapRef.current || !impact?.flood_vector_geojson) return;
const layerId = impact.overlay_id || impact.detection_id || 'current';
const existing = floodVectorLayersRef.current[layerId];
if (existing) existing.remove();
const layer = L.geoJSON(impact.flood_vector_geojson, {
style: {
color: '#dc2626',
weight: 2,
fillColor: '#ef4444',
fillOpacity: 0.26,
},
onEachFeature: (feature, featureLayer) => {
const props = feature?.properties || {};
featureLayer.bindPopup(
`<strong>洪涝矢量</strong><br>` +
`Overlay #${escapeHtml(layerId)}<br>` +
`${escapeHtml(props.name || props.class || '')}`
);
},
});
floodVectorLayersRef.current[layerId] = layer;
layer.addTo(mapRef.current);
const bounds = layer.getBounds();
if (bounds.isValid()) {
mapRef.current.flyToBounds(bounds, { padding: [50, 50], maxZoom: 12 });
}
};
const visualizePair = useCallback((pair) => {
const masterLatLngs = pair.master.coverage_polygon.map(p => [p[1], p[0]]);
const slaveLatLngs = pair.slave.coverage_polygon.map(p => [p[1], p[0]]);
@@ -1644,11 +1735,14 @@ function App() {
};
const floodPanel = {
onShowSourceSceneOnMap: handleWaterSceneOnMap,
onShowSourcePreviewOnMap: handleFloodSourcePreviewOnMap,
onShowReadyProductOnMap: handleWaterSceneOnMap,
onShowOnMap: handleWaterSceneOnMap,
onShowFloodOnMap: handleFloodEventOnMap,
onShowFloodRunOnMap: handleFloodEventOnMap,
onToggleFloodLayer: toggleFloodEventLayer,
onShowFloodPairOnMap: handleFloodPairPreviewOnMap,
onShowFloodVectorOnMap: handleFloodVectorOnMap,
};
const dinsarPanel = {
dinsarCurrentPage,
+60 -8
View File
@@ -7,6 +7,7 @@ const DEFAULT_MONITOR_CONFIG = {
radar_dirs: [],
orbit_dir: '',
dinsar_dirs: [],
gf3_archive_source_dirs: [],
gf3_source_dirs: [],
gf3_storage_dirs: [],
s1_source_dirs: [],
@@ -65,8 +66,9 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
const [s1Loading, setS1Loading] = useState(false);
const [s1ScanLoading, setS1ScanLoading] = useState(false);
const [s1Message, setS1Message] = useState('');
const [gf3Loading, setGf3Loading] = useState(false);
const [gf3UnpackLoading, setGf3UnpackLoading] = useState(false);
const [gf3ProcessLoading, setGf3ProcessLoading] = useState(false);
const [gf3ScanLoading, setGf3ScanLoading] = useState(false);
const [gf3Message, setGf3Message] = useState('');
const logEndRef = useRef(null);
@@ -84,6 +86,9 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
task.task_id === unpackTaskId || task.task_type === 'UNPACK_ARCHIVES'
);
const s1ActiveTask = displayActiveTasks.find((task) => task.task_type === 'UNPACK_SENTINEL1');
const gf3ActiveTask = displayActiveTasks.find((task) =>
['GF3_UNPACK', 'GF3_BATCH_PROCESS'].includes(task.task_type)
);
useEffect(() => {
if (!enabled) {
@@ -113,6 +118,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
s1_source_dirs: toArray(data?.s1_source_dirs),
s1_storage_dirs: toArray(data?.s1_storage_dirs),
s1_orbit_dirs: toArray(data?.s1_orbit_dirs),
gf3_archive_source_dirs: toArray(data?.gf3_archive_source_dirs),
gf3_source_dirs: toArray(data?.gf3_source_dirs),
gf3_storage_dirs: toArray(data?.gf3_storage_dirs),
});
@@ -281,6 +287,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
const hasS1SourceDirs = config.s1_source_dirs.length > 0;
const hasS1StorageDirs = config.s1_storage_dirs.length > 0;
const hasS1OrbitDirs = config.s1_orbit_dirs.length > 0;
const hasGf3ArchiveSourceDirs = config.gf3_archive_source_dirs.length > 0;
const hasGf3SourceDirs = config.gf3_source_dirs.length > 0;
const hasGf3StorageDirs = config.gf3_storage_dirs.length > 0;
@@ -290,6 +297,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
const canRunS1Scan = !readOnly && configLoaded && hasS1SourceDirs;
const canRunS1OrbitScan = !readOnly && configLoaded && hasS1OrbitDirs;
const canRunGf3Scan = !readOnly && configLoaded && hasGf3StorageDirs;
const canRunGf3Unpack = !readOnly && configLoaded && hasGf3ArchiveSourceDirs && hasGf3SourceDirs;
const canRunGf3Process = !readOnly && configLoaded && hasGf3SourceDirs;
const canOpenUnpackDialog = !readOnly && unpackConfig.source_dirs.length > 0;
@@ -409,6 +417,41 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
}
};
const handleGf3Unpack = async () => {
if (readOnly) {
setGf3Message('当前账户为只读模式,无法触发 GF3 解包。');
return;
}
setGf3UnpackLoading(true);
setGf3Message('GF3 解包启动中...');
try {
const res = await fetch(`${apiEndpoint}/monitor/gf3-unpack`, {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({}),
});
const data = await parseJsonSafe(res, {});
if (res.ok) {
setGf3Message(data.message || 'GF3 解包任务已启动');
if (onTaskStart) {
onTaskStart(data.task_id, 'GF3 解包任务已启动。', {
nonBlocking: true,
taskType: 'GF3_UNPACK',
});
}
} else {
setGf3Message(`失败:${data.detail || '未知错误'}`);
}
} catch (err) {
setGf3Message(`失败:${err.message || '未知错误'}`);
} finally {
setGf3UnpackLoading(false);
}
};
const handleOpenUnpackDialog = () => {
if (readOnly) {
setUnpackMessage('当前账户为只读模式,无法触发解包任务。');
@@ -546,7 +589,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
setGf3Message('当前账户为只读模式,无法触发扫描。');
return;
}
setGf3Loading(true);
setGf3ScanLoading(true);
setGf3Message('GF3 扫描启动中...');
try {
const res = await fetch(`${apiEndpoint}/monitor/run-now?target=gf3`, {
@@ -565,7 +608,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
} catch (err) {
setGf3Message(`失败:${err.message || '未知错误'}`);
} finally {
setGf3Loading(false);
setGf3ScanLoading(false);
}
};
@@ -631,6 +674,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
<div style={rowStyle}><span style={labelStyle}>S1 源数据</span><span style={{ wordBreak: 'break-all' }}>{formatList(config.s1_source_dirs)}</span></div>
<div style={rowStyle}><span style={labelStyle}>S1 存储</span><span style={{ wordBreak: 'break-all' }}>{formatList(config.s1_storage_dirs)}</span></div>
<div style={rowStyle}><span style={labelStyle}>S1 精轨</span><span style={{ wordBreak: 'break-all' }}>{formatList(config.s1_orbit_dirs)}</span></div>
<div style={rowStyle}><span style={labelStyle}>GF3 压缩包</span><span style={{ wordBreak: 'break-all' }}>{formatList(config.gf3_archive_source_dirs)}</span></div>
<div style={rowStyle}><span style={labelStyle}>GF3 来源</span><span style={{ wordBreak: 'break-all' }}>{formatList(config.gf3_source_dirs)}</span></div>
<div style={rowStyle}><span style={labelStyle}>GF3 存储</span><span style={{ wordBreak: 'break-all' }}>{formatList(config.gf3_storage_dirs)}</span></div>
<div style={rowStyle}><span style={labelStyle}>D-InSAR 结果</span><span style={{ wordBreak: 'break-all' }}>{formatList(config.dinsar_dirs)}</span></div>
@@ -723,10 +767,18 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
<div style={sectionStyle}>
<div style={{ fontWeight: 'bold', marginBottom: '8px', color: 'var(--color-text-primary)' }}>GF3 归档预处理</div>
<div style={{ ...gridStyle, marginBottom: '8px' }}>
<div style={rowStyle}><span style={labelStyle}>压缩包来源</span><span style={{ wordBreak: 'break-all' }}>{formatList(config.gf3_archive_source_dirs)}</span></div>
<div style={rowStyle}><span style={labelStyle}>L1A 来源</span><span style={{ wordBreak: 'break-all' }}>{formatList(config.gf3_source_dirs)}</span></div>
<div style={rowStyle}><span style={labelStyle}>L2 存储</span><span style={{ wordBreak: 'break-all' }}>{formatList(config.gf3_storage_dirs)}</span></div>
</div>
<div style={{ display: 'flex', gap: '10px' }}>
<div style={{ display: 'flex', gap: '10px', flexWrap: 'wrap' }}>
<button
onClick={handleGf3Unpack}
disabled={gf3UnpackLoading || readOnly || !canRunGf3Unpack}
style={actionBtnStyle(gf3UnpackLoading, readOnly || !canRunGf3Unpack)}
>
{gf3UnpackLoading ? '运行中...' : (readOnly ? '只读模式' : 'GF3 解包')}
</button>
<button
onClick={handleGf3BatchProcess}
disabled={gf3ProcessLoading || readOnly || !canRunGf3Process}
@@ -736,10 +788,10 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
</button>
<button
onClick={handleGf3Scan}
disabled={gf3Loading || readOnly || !canRunGf3Scan}
style={actionBtnStyle(gf3Loading, readOnly || !canRunGf3Scan)}
disabled={gf3ScanLoading || readOnly || !canRunGf3Scan}
style={actionBtnStyle(gf3ScanLoading, readOnly || !canRunGf3Scan)}
>
{gf3Loading ? '运行中...' : (readOnly ? '只读模式' : '扫描 GF3')}
{gf3ScanLoading ? '运行中...' : (readOnly ? '只读模式' : '扫描 GF3')}
</button>
<div
style={{
@@ -748,7 +800,7 @@ const DataMonitorPanel = ({ apiEndpoint, onTaskStart, readOnly = false, enabled
alignSelf: 'center',
}}
>
{gf3Message}
{gf3ActiveTask ? (gf3ActiveTask.message || 'GF3 任务运行中...') : gf3Message}
</div>
</div>
</div>
+505 -49
View File
@@ -3,19 +3,25 @@ import { useCallback, useEffect, useMemo, useState } from 'react';
import UnifiedDatePicker from './components/UnifiedDatePicker';
import { getSearchOptions, searchRadarData } from './api/radar';
import {
createFloodProduct,
getFloodActiveRadarIds,
getFloodDetectionPreview,
getFloodDetections,
getFloodImpact,
getFloodDoneRadarIds,
getFloodProductManifest,
getFloodScenes,
getFloodProducts,
getFloodWaterExtractionPreview,
getFloodWaterExtractions,
resetFloodScene,
searchFloodPairs,
runFloodOverlay,
searchFloodDisasterPairs,
submitFloodDetection,
submitFloodPreprocess,
submitFloodWaterExtraction,
} from './api/flood';
import { getRegionChildren } from './api/aoi';
import {
buildRadarSearchFormData,
formatYmd,
@@ -121,6 +127,11 @@ function compactDate(value) {
return String(value || '').replaceAll('-', '').trim();
}
function formatPercent(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return '-';
return `${(Number(value) * 100).toFixed(1)}%`;
}
function formatArea(value) {
if (value === null || value === undefined || Number.isNaN(Number(value))) return '-';
return `${Number(value).toFixed(2)} km²`;
@@ -225,6 +236,37 @@ function KeyValue({ label, value, strong = false }) {
);
}
function RegionSelector({ options, selection, onProvinceChange, onCityChange, disabled = false }) {
const provinces = options?.provinces || [];
const cities = options?.cities || [];
return (
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<select
value={selection?.province || ''}
onChange={event => onProvinceChange(event.target.value)}
style={inputStyle}
disabled={disabled}
>
<option value="">省/自治区</option>
{provinces.map(item => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
</select>
<select
value={selection?.city || ''}
onChange={event => onCityChange(event.target.value)}
style={inputStyle}
disabled={disabled || !selection?.province}
>
<option value="">市/州(可选)</option>
{cities.map(item => (
<option key={item.tree_id} value={item.tree_id}>{item.name}</option>
))}
</select>
</div>
);
}
function normalizeMapPreview(preview) {
if (!preview) return null;
const image = preview.image_b64 || preview.png_base64;
@@ -239,6 +281,11 @@ function normalizeMapPreview(preview) {
function SceneRow({ scene, readOnly, onShowMap, onExtractWater, onReset }) {
const status = asStatus(scene.status);
const canExtract = status === 'DONE';
const [coverageVisible, setCoverageVisible] = useState(false);
const handleToggleCoverage = () => {
const visible = onShowMap(scene);
if (typeof visible === 'boolean') setCoverageVisible(visible);
};
return (
<div style={rowStyle}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 8, alignItems: 'start' }}>
@@ -248,13 +295,16 @@ function SceneRow({ scene, readOnly, onShowMap, onExtractWater, onReset }) {
<StatusBadge status={scene.status} />
<span style={{ color: palette.muted }}>{scene.satellite || '-'}</span>
<span style={{ color: palette.muted }}>{formatYmd(scene.imaging_date, 'zh')}</span>
{scene.polarization && <span style={{ color: palette.subtle }}>{scene.polarization}</span>}
</div>
<div style={{ color: palette.subtle, fontSize: 11, marginTop: 5, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={scene.geo_path || scene.error_msg || ''}>
{scene.geo_path ? scene.geo_path.split(/[\\/]/).pop() : scene.error_msg || `Radar ID ${scene.radar_data_id}`}
{[scene.imaging_mode, scene.product_level, scene.geo_path ? scene.geo_path.split(/[\\/]/).pop() : scene.error_msg || `Radar ID ${scene.radar_data_id}`].filter(Boolean).join(' · ')}
</div>
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<button type="button" style={buttonStyle('quiet', !scene.coverage_polygon)} disabled={!scene.coverage_polygon} onClick={() => onShowMap(scene)}>覆盖</button>
<button type="button" style={buttonStyle(coverageVisible ? 'success' : 'quiet', !scene.coverage_polygon)} disabled={!scene.coverage_polygon} onClick={handleToggleCoverage}>
{coverageVisible ? '清除范围' : '显示范围'}
</button>
<button type="button" style={buttonStyle('primary', readOnly || !canExtract)} disabled={readOnly || !canExtract} onClick={() => onExtractWater(scene)}>提取水体</button>
{isActiveStatus(scene.status) && (
<button type="button" style={buttonStyle('danger', readOnly)} disabled={readOnly} onClick={() => onReset(scene)}>重置</button>
@@ -274,6 +324,9 @@ function WaterResultRow({ item, onShowMap }) {
<strong>水体 #{item.id}</strong>
<StatusBadge status={item.status} />
{item.scene_id && <span style={{ color: palette.muted }}>场景 #{item.scene_id}</span>}
{item.satellite && <span style={{ color: palette.muted }}>{item.satellite}</span>}
{item.imaging_date && <span style={{ color: palette.muted }}>{formatYmd(item.imaging_date, 'zh')}</span>}
{item.polarization && <span style={{ color: palette.subtle }}>{item.polarization}</span>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 8, marginTop: 8 }}>
<KeyValue label="面积" value={formatArea(item.water_area_km2)} strong />
@@ -288,7 +341,29 @@ function WaterResultRow({ item, onShowMap }) {
);
}
function FloodEventRow({ event, mapLayerVis, mapLoading, onShowMap, onToggleLayer, onSelectImpact }) {
function FloodProductRow({ product, onOpenManifest }) {
return (
<div style={rowStyle}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 8, alignItems: 'center' }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<strong>{product.product_id || `产品 #${product.id}`}</strong>
<StatusBadge status={product.status} />
{product.detection_id && <span style={{ color: palette.muted }}>洪涝 #{product.detection_id}</span>}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 8, marginTop: 8 }}>
<KeyValue label="洪涝面积" value={formatArea(product.flood_area_km2 || product.summary?.flood_area_km2)} strong />
<KeyValue label="影响面积" value={formatArea(product.affected_area_km2)} />
<KeyValue label="生成时间" value={formatYmd(product.created_at, 'zh')} />
</div>
</div>
<button type="button" style={buttonStyle('secondary')} onClick={() => onOpenManifest(product)}>Manifest</button>
</div>
</div>
);
}
function FloodEventRow({ event, mapLayerVis, mapLoading, productBusy, onShowMap, onToggleLayer, onSelectImpact, onCreateProduct }) {
const done = asStatus(event.status) === 'DONE';
const preDate = formatYmd(event.pre_imaging_date, 'zh');
const postDate = formatYmd(event.post_imaging_date, 'zh');
@@ -312,9 +387,9 @@ function FloodEventRow({ event, mapLayerVis, mapLoading, onShowMap, onToggleLaye
<KeyValue label="稳定水体" value={formatArea(event.stable_water_area_km2)} />
</div>
{event.error_msg && <div style={{ color: palette.red, fontSize: 11, marginTop: 8 }}>{event.error_msg}</div>}
{layers && (
{(layers || onSelectImpact || onCreateProduct) && (
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 9 }}>
{[
{layers && [
['pre', '灾前影像'],
['post', '灾后影像'],
['classified', '分类结果'],
@@ -333,6 +408,11 @@ function FloodEventRow({ event, mapLayerVis, mapLoading, onShowMap, onToggleLaye
套合分析
</button>
)}
{onCreateProduct && (
<button type="button" style={buttonStyle('primary', productBusy, { padding: '3px 8px', fontSize: 11 })} disabled={productBusy} onClick={() => onCreateProduct(event)}>
{productBusy ? '生成中' : '生成产品'}
</button>
)}
</div>
)}
</div>
@@ -357,6 +437,8 @@ export default function FloodAnalysisWorkspace({
const [radarLoading, setRadarLoading] = useState(false);
const [radarSearched, setRadarSearched] = useState(false);
const [selectedRadars, setSelectedRadars] = useState([]);
const [sourceCoverageVisibleIds, setSourceCoverageVisibleIds] = useState(() => new Set());
const [sourcePreviewVisibleIds, setSourcePreviewVisibleIds] = useState(() => new Set());
const [scenes, setScenes] = useState([]);
const [scenesTotal, setScenesTotal] = useState(0);
@@ -370,10 +452,21 @@ export default function FloodAnalysisWorkspace({
const [waterPage, setWaterPage] = useState(0);
const [waterLoading, setWaterLoading] = useState(false);
const [pairPreStart, setPairPreStart] = useState('');
const [pairPreEnd, setPairPreEnd] = useState('');
const [pairPostStart, setPairPostStart] = useState('');
const [pairPostEnd, setPairPostEnd] = useState('');
const [sourceAoiMode, setSourceAoiMode] = useState('none');
const [sourceRegionOptions, setSourceRegionOptions] = useState({ provinces: [], cities: [] });
const [sourceRegionSelection, setSourceRegionSelection] = useState({ province: '', city: '' });
const [regionLoading, setRegionLoading] = useState(false);
const [regionError, setRegionError] = useState('');
const [disasterName, setDisasterName] = useState('');
const [disasterDate, setDisasterDate] = useState('');
const [preWindowDays, setPreWindowDays] = useState(30);
const [postWindowDays, setPostWindowDays] = useState(30);
const [minAoiCoverage, setMinAoiCoverage] = useState(0.2);
const [pairRegionOptions, setPairRegionOptions] = useState({ provinces: [], cities: [] });
const [pairRegionSelection, setPairRegionSelection] = useState({ province: '', city: '' });
const [pairSearchSummary, setPairSearchSummary] = useState(null);
const [overlapThreshold, setOverlapThreshold] = useState(0.3);
const [refine, setRefine] = useState(false);
const [pairSearching, setPairSearching] = useState(false);
@@ -382,9 +475,15 @@ export default function FloodAnalysisWorkspace({
const [floodEvents, setFloodEvents] = useState([]);
const [floodLoading, setFloodLoading] = useState(false);
const [floodProducts, setFloodProducts] = useState([]);
const [productLoading, setProductLoading] = useState(false);
const [productBusyId, setProductBusyId] = useState(null);
const [mapLoadingId, setMapLoadingId] = useState(null);
const [mapLayerVis, setMapLayerVis] = useState({});
const [selectedImpactEventId, setSelectedImpactEventId] = useState('');
const [impactLoading, setImpactLoading] = useState(false);
const [overlayRunning, setOverlayRunning] = useState(false);
const [impactResult, setImpactResult] = useState(null);
const [resultMode, setResultMode] = useState('flood');
const readyScenes = useMemo(() => scenes.filter(item => asStatus(item.status) === 'DONE'), [scenes]);
@@ -400,11 +499,52 @@ export default function FloodAnalysisWorkspace({
() => doneFloodEvents.find(item => String(item.id) === String(selectedImpactEventId)) || doneFloodEvents[0] || null,
[doneFloodEvents, selectedImpactEventId],
);
const selectedSourceRegionTreeId = sourceRegionSelection.city || sourceRegionSelection.province || '';
const selectedPairRegionTreeId = pairRegionSelection.city || pairRegionSelection.province || '';
const showMessage = useCallback((type, text) => {
setMessage({ type, text });
}, []);
const loadRegionProvinces = useCallback(async () => {
setRegionLoading(true);
setRegionError('');
try {
const data = await getRegionChildren('1');
const provinces = data.children || [];
setSourceRegionOptions(prev => ({ ...prev, provinces }));
setPairRegionOptions(prev => ({ ...prev, provinces }));
} catch (error) {
setRegionError(getErrorText(error, '行政区加载失败'));
} finally {
setRegionLoading(false);
}
}, []);
const updateRegionProvince = useCallback(async (target, provinceTreeId) => {
const setSelection = target === 'source' ? setSourceRegionSelection : setPairRegionSelection;
const setOptions = target === 'source' ? setSourceRegionOptions : setPairRegionOptions;
setSelection({ province: provinceTreeId, city: '' });
setOptions(prev => ({ ...prev, cities: [] }));
if (!provinceTreeId) return;
setRegionLoading(true);
setRegionError('');
try {
const data = await getRegionChildren(provinceTreeId);
setOptions(prev => ({ ...prev, cities: data.children || [] }));
} catch (error) {
setRegionError(getErrorText(error, '行政区加载失败'));
} finally {
setRegionLoading(false);
}
}, []);
const updateRegionCity = useCallback((target, cityTreeId) => {
const setSelection = target === 'source' ? setSourceRegionSelection : setPairRegionSelection;
setSelection(prev => ({ ...prev, city: cityTreeId }));
}, []);
const loadStatusIds = useCallback(async () => {
try {
const [done, active] = await Promise.all([getFloodDoneRadarIds(), getFloodActiveRadarIds()]);
@@ -461,14 +601,28 @@ export default function FloodAnalysisWorkspace({
}
}, [showMessage]);
const loadFloodProducts = useCallback(async () => {
setProductLoading(true);
try {
const res = (await getFloodProducts({ limit: LIST_PAGE_SIZE, offset: 0 })).data;
setFloodProducts(res.items || []);
} catch (error) {
setFloodProducts([]);
showMessage('error', `洪涝产品加载失败:${getErrorText(error)}`);
} finally {
setProductLoading(false);
}
}, [showMessage]);
const refreshAll = useCallback(async () => {
await Promise.all([
loadStatusIds(),
loadScenes(scenesPage),
loadWaterResults(waterPage),
loadFloodEvents(),
loadFloodProducts(),
]);
}, [loadFloodEvents, loadScenes, loadStatusIds, loadWaterResults, scenesPage, waterPage]);
}, [loadFloodEvents, loadFloodProducts, loadScenes, loadStatusIds, loadWaterResults, scenesPage, waterPage]);
useEffect(() => {
getSearchOptions()
@@ -479,8 +633,9 @@ export default function FloodAnalysisWorkspace({
polarization: data.polarization || [],
}))
.catch(() => {});
loadRegionProvinces();
refreshAll();
}, [refreshAll]);
}, [loadRegionProvinces, refreshAll]);
useEffect(() => {
if (!runningCount) return undefined;
@@ -504,12 +659,16 @@ export default function FloodAnalysisWorkspace({
setRadarLoading(true);
setRadarSearched(true);
try {
if (sourceAoiMode === 'region' && !selectedSourceRegionTreeId) {
throw new Error('请选择用于筛选的行政区。');
}
const criteria = normalizeRadarSearchCriteria(radarDraft, RADAR_SEARCH_DEFAULTS);
const formData = buildRadarSearchFormData({
limit: SEARCH_PAGE_SIZE,
offset: page * SEARCH_PAGE_SIZE,
criteria,
aoiMode: 'none',
aoiMode: sourceAoiMode,
regionTreeId: sourceAoiMode === 'region' ? selectedSourceRegionTreeId : '',
});
const data = await searchRadarData(formData);
setRadarResults(data.items || []);
@@ -526,6 +685,9 @@ export default function FloodAnalysisWorkspace({
const resetRadarSearch = () => {
setRadarDraft({ ...RADAR_SEARCH_DEFAULTS });
setSourceAoiMode('none');
setSourceRegionSelection({ province: '', city: '' });
setSourceRegionOptions(prev => ({ ...prev, cities: [] }));
setRadarResults([]);
setRadarTotal(0);
setRadarPage(0);
@@ -541,6 +703,35 @@ export default function FloodAnalysisWorkspace({
));
};
const updateVisibleIdSet = (setter, itemId, visible) => {
setter(prev => {
const next = new Set(prev);
if (visible) next.add(String(itemId));
else next.delete(String(itemId));
return next;
});
};
const handleToggleSourceCoverage = (item) => {
const handler = floodPanel.onShowSourceSceneOnMap || floodPanel.onShowOnMap;
if (!handler) return;
const visible = handler(item);
if (typeof visible === 'boolean') {
updateVisibleIdSet(setSourceCoverageVisibleIds, item.id, visible);
showMessage(visible ? 'success' : 'info', visible ? `范围框 #${item.id} 已显示。` : `范围框 #${item.id} 已清除。`);
}
};
const handleToggleSourcePreview = (item) => {
const handler = floodPanel.onShowSourcePreviewOnMap || floodPanel.onShowSourceSceneOnMap || floodPanel.onShowOnMap;
if (!handler) return;
const visible = handler(item);
if (typeof visible === 'boolean') {
updateVisibleIdSet(setSourcePreviewVisibleIds, item.id, visible);
showMessage(visible ? 'success' : 'info', visible ? `源影像 #${item.id} 已显示。` : `源影像 #${item.id} 已清除。`);
}
};
const handleSubmitPreprocess = async () => {
if (readOnly || selectedRadars.length === 0) return;
setActionBusy('geocode');
@@ -615,17 +806,36 @@ export default function FloodAnalysisWorkspace({
setPairSearching(true);
setCandidatePairs([]);
setSelectedPairIdx(null);
setPairSearchSummary(null);
try {
const data = await searchFloodPairs({
pre_start: compactDate(pairPreStart),
pre_end: compactDate(pairPreEnd),
post_start: compactDate(pairPostStart),
post_end: compactDate(pairPostEnd),
overlap_threshold: Number(overlapThreshold) || 0,
if (!compactDate(disasterDate)) {
throw new Error('请选择灾害发生日期。');
}
if (!selectedPairRegionTreeId) {
throw new Error('请选择灾害影响位置。');
}
const data = await searchFloodDisasterPairs({
disaster_name: disasterName || undefined,
disaster_date: compactDate(disasterDate),
region_tree_id: selectedPairRegionTreeId,
pre_window_days: Number(preWindowDays) || 30,
post_window_days: Number(postWindowDays) || 30,
min_aoi_coverage_ratio: Number(minAoiCoverage) || 0,
min_pair_overlap_ratio: Number(overlapThreshold) || 0,
polarization: radarDraft.polarization || undefined,
imaging_mode: radarDraft.imaging_mode || undefined,
product_level: radarDraft.product_level || undefined,
require_same_polarization: true,
require_same_imaging_mode: false,
});
setCandidatePairs(data.pairs || []);
setSelectedPairIdx(data.pairs?.length ? 0 : null);
showMessage(data.pairs?.length ? 'success' : 'warn', data.pairs?.length ? `找到 ${data.pairs.length} 组候选配对。` : '没有找到满足条件的配对。');
const pairs = data.candidate_pairs || data.pairs || [];
setPairSearchSummary(data.summary || null);
setCandidatePairs(pairs);
setSelectedPairIdx(pairs.length ? 0 : null);
showMessage(
pairs.length ? 'success' : 'warn',
pairs.length ? `找到 ${pairs.length} 组候选配对。` : (data.warnings?.[0] || '没有找到满足条件的配对。'),
);
} catch (error) {
showMessage('error', `配对推荐失败:${getErrorText(error)}`);
} finally {
@@ -685,6 +895,80 @@ export default function FloodAnalysisWorkspace({
}
};
const handleShowPairCoverage = (pair) => {
if (!pair?.pre?.coverage_polygon || !pair?.post?.coverage_polygon) {
showMessage('warn', '该候选配对缺少覆盖范围,无法上图。');
return;
}
floodPanel.onShowFloodPairOnMap?.(pair);
showMessage('success', '候选配对覆盖范围已加载到地图。');
};
const handleShowFloodVector = () => {
if (!impactResult?.flood_vector_geojson) {
showMessage('warn', '该套合结果没有可用洪涝矢量。');
return;
}
floodPanel.onShowFloodVectorOnMap?.(impactResult);
showMessage('success', '洪涝矢量已加载到地图。');
};
const loadImpactResult = useCallback(async (detectionId, { silent = false } = {}) => {
if (!detectionId) return;
setImpactLoading(true);
try {
const data = await getFloodImpact(detectionId);
setImpactResult(data);
if (!silent && data?.warnings?.includes?.('overlay has not been run')) {
showMessage('warn', '该洪涝结果尚未运行套合分析。');
}
} catch (error) {
setImpactResult(null);
if (!silent) showMessage('error', `套合结果加载失败:${getErrorText(error)}`);
} finally {
setImpactLoading(false);
}
}, [showMessage]);
const handleRunOverlay = async () => {
if (readOnly || !selectedImpactEvent) return;
setOverlayRunning(true);
try {
await runFloodOverlay(selectedImpactEvent.id, { near_threshold_m: 500 });
await loadImpactResult(selectedImpactEvent.id, { silent: true });
await loadFloodEvents();
showMessage('success', `洪涝结果 #${selectedImpactEvent.id} 套合分析已完成。`);
} catch (error) {
showMessage('error', `套合分析失败:${getErrorText(error)}`);
} finally {
setOverlayRunning(false);
}
};
const handleCreateFloodProduct = async (event) => {
if (readOnly || !event) return;
setProductBusyId(event.id);
try {
await createFloodProduct(event.id);
await loadFloodProducts();
showMessage('success', `洪涝结果 #${event.id} 产品已生成。`);
setResultMode('products');
} catch (error) {
showMessage('error', `产品生成失败:${getErrorText(error)}`);
} finally {
setProductBusyId(null);
}
};
const handleOpenProductManifest = async (product) => {
try {
const manifest = await getFloodProductManifest(product.id || product.product_id);
showMessage('success', `Manifest 已读取:${manifest?.schema || product.product_id || product.id}`);
} catch (error) {
showMessage('error', `Manifest 读取失败:${getErrorText(error)}`);
}
};
const handleToggleFloodLayer = (eventId, key, visible) => {
setMapLayerVis(prev => ({ ...prev, [eventId]: { ...prev[eventId], [key]: visible } }));
floodPanel.onToggleFloodLayer?.(eventId, key, visible);
@@ -692,9 +976,15 @@ export default function FloodAnalysisWorkspace({
const handleSelectImpact = (event) => {
setSelectedImpactEventId(String(event.id));
setImpactResult(null);
setActiveView('impact');
};
useEffect(() => {
if (activeView !== 'impact' || !selectedImpactEvent) return;
loadImpactResult(selectedImpactEvent.id, { silent: true });
}, [activeView, loadImpactResult, selectedImpactEvent]);
const radarTotalPages = Math.ceil(radarTotal / SEARCH_PAGE_SIZE);
const scenesTotalPages = Math.ceil(scenesTotal / LIST_PAGE_SIZE);
const waterTotalPages = Math.ceil(waterTotal / LIST_PAGE_SIZE);
@@ -799,6 +1089,23 @@ export default function FloodAnalysisWorkspace({
{searchOptions.polarization.map(item => <option key={item} value={item}>{item}</option>)}
</select>
</div>
<div style={{ display: 'grid', gap: 6, marginBottom: 10 }}>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
<span style={{ color: palette.muted, fontSize: 12 }}>位置过滤</span>
<button type="button" style={buttonStyle(sourceAoiMode === 'none' ? 'primary' : 'quiet')} onClick={() => setSourceAoiMode('none')}>不限</button>
<button type="button" style={buttonStyle(sourceAoiMode === 'region' ? 'primary' : 'quiet', regionLoading)} disabled={regionLoading} onClick={() => setSourceAoiMode('region')}>行政区</button>
</div>
{sourceAoiMode === 'region' && (
<RegionSelector
options={sourceRegionOptions}
selection={sourceRegionSelection}
onProvinceChange={value => updateRegionProvince('source', value)}
onCityChange={value => updateRegionCity('source', value)}
disabled={regionLoading}
/>
)}
{regionError && <div style={{ color: palette.red, fontSize: 11 }}>{regionError}</div>}
</div>
<div style={{ display: 'grid', gap: 6 }}>
{!radarSearched && <EmptyState>输入条件后查询入库雷达数据。</EmptyState>}
@@ -807,6 +1114,8 @@ export default function FloodAnalysisWorkspace({
const selected = selectedRadars.some(row => row.id === item.id);
const done = doneRadarIds.includes(item.id);
const active = activeRadarIds.includes(item.id);
const coverageVisible = sourceCoverageVisibleIds.has(String(item.id));
const previewVisible = sourcePreviewVisibleIds.has(String(item.id));
return (
<div key={item.id} style={{ ...rowStyle, background: selected ? '#eff6ff' : palette.panel }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr auto', gap: 8, alignItems: 'center' }}>
@@ -822,14 +1131,32 @@ export default function FloodAnalysisWorkspace({
{[item.imaging_mode, item.product_level, item.polarization].filter(Boolean).join(' · ') || '-'}
</div>
</div>
<button
type="button"
style={buttonStyle(selected ? 'success' : 'primary', done || active)}
disabled={done || active}
onClick={() => toggleRadarSelection(item)}
>
{selected ? '已选' : '选择'}
</button>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', justifyContent: 'flex-end' }}>
<button
type="button"
style={buttonStyle(coverageVisible ? 'success' : 'quiet', !item.coverage_polygon)}
disabled={!item.coverage_polygon}
onClick={() => handleToggleSourceCoverage(item)}
>
{coverageVisible ? '清除范围' : '显示范围'}
</button>
<button
type="button"
style={buttonStyle(previewVisible ? 'success' : 'secondary', !(item.min_lat != null && item.max_lat != null && item.min_lon != null && item.max_lon != null))}
disabled={!(item.min_lat != null && item.max_lat != null && item.min_lon != null && item.max_lon != null)}
onClick={() => handleToggleSourcePreview(item)}
>
{previewVisible ? '清除源影像' : '显示源影像'}
</button>
<button
type="button"
style={buttonStyle(selected ? 'success' : 'primary', done || active)}
disabled={done || active}
onClick={() => toggleRadarSelection(item)}
>
{selected ? '已选' : '选择'}
</button>
</div>
</div>
</div>
);
@@ -911,19 +1238,47 @@ export default function FloodAnalysisWorkspace({
</>
)}
/>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6, marginBottom: 8 }}>
<UnifiedDatePicker value={pairPreStart} onChange={setPairPreStart} placeholder="灾前开始" language={language} />
<UnifiedDatePicker value={pairPreEnd} onChange={setPairPreEnd} placeholder="灾前结束" language={language} />
<UnifiedDatePicker value={pairPostStart} onChange={setPairPostStart} placeholder="灾后开始" language={language} />
<UnifiedDatePicker value={pairPostEnd} onChange={setPairPostEnd} placeholder="灾后结束" language={language} />
<label style={{ display: 'flex', alignItems: 'center', gap: 6, color: palette.text2, fontSize: 12 }}>
重叠阈值
<input type="number" min="0" max="1" step="0.05" value={overlapThreshold} onChange={event => setOverlapThreshold(event.target.value)} style={{ ...inputStyle, width: 80 }} />
</label>
<div style={{ display: 'grid', gap: 8, marginBottom: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 6 }}>
<input value={disasterName} onChange={event => setDisasterName(event.target.value)} placeholder="灾害名称(可选)" style={inputStyle} />
<UnifiedDatePicker value={disasterDate} onChange={setDisasterDate} placeholder="灾害发生日期" language={language} />
</div>
<RegionSelector
options={pairRegionOptions}
selection={pairRegionSelection}
onProvinceChange={value => updateRegionProvince('pair', value)}
onCityChange={value => updateRegionCity('pair', value)}
disabled={regionLoading}
/>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 6 }}>
<label style={{ color: palette.text2, fontSize: 12 }}>
灾前窗口(天)
<input type="number" min="1" max="365" value={preWindowDays} onChange={event => setPreWindowDays(event.target.value)} style={inputStyle} />
</label>
<label style={{ color: palette.text2, fontSize: 12 }}>
灾后窗口(天)
<input type="number" min="1" max="365" value={postWindowDays} onChange={event => setPostWindowDays(event.target.value)} style={inputStyle} />
</label>
<label style={{ color: palette.text2, fontSize: 12 }}>
AOI覆盖
<input type="number" min="0" max="1" step="0.05" value={minAoiCoverage} onChange={event => setMinAoiCoverage(event.target.value)} style={inputStyle} />
</label>
<label style={{ color: palette.text2, fontSize: 12 }}>
配对重叠
<input type="number" min="0" max="1" step="0.05" value={overlapThreshold} onChange={event => setOverlapThreshold(event.target.value)} style={inputStyle} />
</label>
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, color: palette.text2, fontSize: 12 }}>
<input type="checkbox" checked={refine} onChange={event => setRefine(event.target.checked)} style={{ accentColor: palette.primary }} />
MRF 精化
</label>
{pairSearchSummary && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 8 }}>
<KeyValue label="灾前匹配池" value={String(pairSearchSummary.pre_pool_count || 0)} />
<KeyValue label="灾后匹配池" value={String(pairSearchSummary.post_pool_count || 0)} />
<KeyValue label="候选配对" value={String(pairSearchSummary.candidate_count || 0)} strong />
</div>
)}
</div>
<div style={{ display: 'grid', gap: 8 }}>
{candidatePairs.length === 0 && <EmptyState>暂无候选配对。</EmptyState>}
@@ -946,12 +1301,37 @@ export default function FloodAnalysisWorkspace({
<strong>#{pair.pre.id} {formatYmd(pair.pre.imaging_date, language)}</strong>
<span style={{ color: palette.muted }}>{'->'}</span>
<strong>#{pair.post.id} {formatYmd(pair.post.imaging_date, language)}</strong>
{pair.score != null && <StatusBadge tone="info">评分 {Number(pair.score).toFixed(2)}</StatusBadge>}
<StatusBadge tone={active ? 'info' : 'muted'}>{active ? '已选' : '候选'}</StatusBadge>
</div>
<div style={{ color: palette.muted, fontSize: 11, marginTop: 5 }}>
重叠 {(Number(pair.overlap_ratio || 0) * 100).toFixed(1)}%
配对重叠 {formatPercent(pair.overlap_ratio)}
{pair.aoi_coverage_ratio != null ? ` · AOI覆盖 ${formatPercent(pair.aoi_coverage_ratio)}` : ''}
{pair.time_diff_days != null ? ` · 间隔 ${pair.time_diff_days} 天` : ''}
{pair.pre_delta_days != null ? ` · 灾前 ${pair.pre_delta_days} 天` : ''}
{pair.post_delta_days != null ? ` · 灾后 ${pair.post_delta_days} 天` : ''}
{pair.pre.satellite ? ` · ${pair.pre.satellite}` : ''}
{pair.pre.polarization ? ` · ${pair.pre.polarization}` : ''}
</div>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginTop: 8 }}>
<span
role="button"
tabIndex={0}
style={buttonStyle('secondary', false, { display: 'inline-flex', padding: '3px 8px', fontSize: 11 })}
onClick={event => {
event.stopPropagation();
handleShowPairCoverage(pair);
}}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
handleShowPairCoverage(pair);
}
}}
>
预览覆盖
</span>
</div>
</button>
);
@@ -973,6 +1353,8 @@ export default function FloodAnalysisWorkspace({
onShowMap={handleShowFloodEvent}
onToggleLayer={handleToggleFloodLayer}
onSelectImpact={handleSelectImpact}
onCreateProduct={handleCreateFloodProduct}
productBusy={productBusyId === event.id}
/>
))}
</div>
@@ -983,15 +1365,18 @@ export default function FloodAnalysisWorkspace({
{activeView === 'impact' && (
<main>
<section style={{ ...sectionStyle, borderTop: 'none' }}>
<SectionHeader title="套合对象" />
<SectionHeader
title="套合对象"
actions={<button type="button" style={buttonStyle('quiet', impactLoading)} disabled={impactLoading || !selectedImpactEvent} onClick={() => selectedImpactEvent && loadImpactResult(selectedImpactEvent.id)}>刷新结果</button>}
/>
<div style={{ display: 'grid', gap: 8 }}>
{[
['洪涝分类栅格', selectedImpactEvent ? `结果 #${selectedImpactEvent.id}` : '请选择结果', selectedImpactEvent ? 'ok' : 'muted'],
['灾害点', '已有底库', 'ok'],
['洪涝矢量化', '待接入', 'warn'],
['行政区统计', '待接入', 'muted'],
['当前 AOI', '待接入', 'muted'],
['自定义矢量', '待接入', 'muted'],
['洪涝矢量化', impactResult?.warnings?.includes?.('overlay has not been run') ? '未生成' : (impactResult ? '已生成' : '待运行'), impactResult && !impactResult?.warnings?.includes?.('overlay has not been run') ? 'ok' : 'warn'],
['灾害点命中', impactResult ? `${impactResult.hazard_points?.inside_flood?.length || 0} 个` : '待运行', impactResult ? 'ok' : 'muted'],
['近洪涝风险点', impactResult ? `${impactResult.hazard_points?.near_flood?.length || 0} 个` : '待运行', impactResult ? 'info' : 'muted'],
['DInSAR关联', impactResult ? `${impactResult.dinsar_products?.length || 0} 个` : '待运行', impactResult ? 'info' : 'muted'],
['行政区统计', impactResult?.affected_aois?.length ? `${impactResult.affected_aois.length} 个` : '暂无数据', impactResult ? 'muted' : 'muted'],
].map(([name, status, tone]) => (
<div key={name} style={{ ...rowStyle, display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: 8 }}>
<strong>{name}</strong>
@@ -1035,10 +1420,69 @@ export default function FloodAnalysisWorkspace({
</div>
<div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginTop: 10 }}>
<button type="button" style={buttonStyle('secondary', !selectedImpactEvent)} disabled={!selectedImpactEvent} onClick={() => selectedImpactEvent && handleShowFloodEvent(selectedImpactEvent)}>加载洪涝图层</button>
<button type="button" style={buttonStyle('quiet', true)} disabled>运行套合分析</button>
<button type="button" style={buttonStyle('secondary', !impactResult?.flood_vector_geojson)} disabled={!impactResult?.flood_vector_geojson} onClick={handleShowFloodVector}>加载洪涝矢量</button>
<button type="button" style={buttonStyle('primary', readOnly || !selectedImpactEvent || overlayRunning)} disabled={readOnly || !selectedImpactEvent || overlayRunning} onClick={handleRunOverlay}>
{overlayRunning ? '套合中' : '运行套合分析'}
</button>
<button type="button" style={buttonStyle('quiet', true)} disabled>导出影响清单</button>
</div>
</section>
<section style={sectionStyle}>
<SectionHeader title="套合结果" />
{impactLoading && <EmptyState>套合结果加载中...</EmptyState>}
{!impactLoading && !impactResult && <EmptyState>选择一个已完成的洪涝结果后运行套合分析。</EmptyState>}
{!impactLoading && impactResult && (
<div style={{ display: 'grid', gap: 10 }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 8 }}>
<KeyValue label="洪涝面积" value={formatArea(impactResult.flood_area_km2)} strong />
<KeyValue label="命中灾害点" value={String(impactResult.hazard_points?.inside_flood?.length || 0)} strong />
<KeyValue label="近洪涝风险点" value={String(impactResult.hazard_points?.near_flood?.length || 0)} />
<KeyValue label="DInSAR产品" value={String(impactResult.dinsar_products?.length || 0)} />
<KeyValue label="影响行政区" value={String(impactResult.affected_aois?.length || 0)} />
</div>
{impactResult.warnings?.length > 0 && (
<div style={{ color: palette.amber, fontSize: 11 }}>
{impactResult.warnings.join(';')}
</div>
)}
<div style={{ display: 'grid', gap: 8 }}>
{(impactResult.hazard_points?.inside_flood || []).slice(0, 5).map(point => (
<div key={`inside_${point.id}`} style={rowStyle}>
<strong>{point.name || `灾害点 #${point.id}`}</strong>
<div style={{ color: palette.muted, fontSize: 11, marginTop: 4 }}>
洪涝范围内 · {[point.type, point.city, point.county].filter(Boolean).join(' / ') || '-'}
</div>
</div>
))}
{(impactResult.hazard_points?.near_flood || []).slice(0, 5).map(point => (
<div key={`near_${point.id}`} style={rowStyle}>
<strong>{point.name || `灾害点 #${point.id}`}</strong>
<div style={{ color: palette.muted, fontSize: 11, marginTop: 4 }}>
距洪涝范围 {point.distance_m ?? '-'} m · {[point.type, point.city, point.county].filter(Boolean).join(' / ') || '-'}
</div>
</div>
))}
{(impactResult.dinsar_products || []).slice(0, 5).map(product => (
<div key={`dinsar_${product.id || product.product_id}`} style={rowStyle}>
<strong>{product.display_name || product.product_id}</strong>
<div style={{ color: palette.muted, fontSize: 11, marginTop: 4 }}>
{product.engine || '-'} · 形变 {product.deformation_mm ?? '-'} mm · AI {product.ai_score ?? '-'}
</div>
</div>
))}
{(impactResult.affected_aois || []).slice(0, 5).map(aoi => (
<div key={`aoi_${aoi.tree_id || aoi.name}`} style={rowStyle}>
<strong>{aoi.name || aoi.tree_id}</strong>
<div style={{ color: palette.muted, fontSize: 11, marginTop: 4 }}>
{aoi.level || '-'} · 受淹面积 {formatArea(aoi.flood_area_km2)}
</div>
</div>
))}
</div>
</div>
)}
</section>
</main>
)}
@@ -1051,6 +1495,7 @@ export default function FloodAnalysisWorkspace({
<>
<button type="button" style={buttonStyle(resultMode === 'flood' ? 'primary' : 'quiet')} onClick={() => setResultMode('flood')}>洪涝</button>
<button type="button" style={buttonStyle(resultMode === 'water' ? 'primary' : 'quiet')} onClick={() => setResultMode('water')}>水体</button>
<button type="button" style={buttonStyle(resultMode === 'products' ? 'primary' : 'quiet')} onClick={() => setResultMode('products')}>产品</button>
<button type="button" style={buttonStyle('secondary')} onClick={refreshAll}>刷新</button>
</>
)}
@@ -1067,6 +1512,8 @@ export default function FloodAnalysisWorkspace({
onShowMap={handleShowFloodEvent}
onToggleLayer={handleToggleFloodLayer}
onSelectImpact={handleSelectImpact}
onCreateProduct={handleCreateFloodProduct}
productBusy={productBusyId === event.id}
/>
))}
</div>
@@ -1079,13 +1526,22 @@ export default function FloodAnalysisWorkspace({
))}
</div>
)}
{resultMode === 'products' && (
<div style={{ display: 'grid', gap: 8 }}>
{productLoading && <EmptyState>洪涝产品加载中...</EmptyState>}
{!productLoading && floodProducts.length === 0 && <EmptyState>暂无洪涝产品。可在洪涝结果行点击“生成产品”。</EmptyState>}
{!productLoading && floodProducts.map(product => (
<FloodProductRow key={product.id || product.product_id} product={product} onOpenManifest={handleOpenProductManifest} />
))}
</div>
)}
</section>
<section style={sectionStyle}>
<SectionHeader title="产品出口" />
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, minmax(0, 1fr))', gap: 8 }}>
<button type="button" style={buttonStyle('quiet', true)} disabled>GeoTIFF</button>
<button type="button" style={buttonStyle('quiet', true)} disabled>GeoJSON</button>
<button type="button" style={buttonStyle('secondary')} onClick={() => setResultMode('products')}>产品列表</button>
<button type="button" style={buttonStyle('quiet', true)} disabled>GeoJSON 导出</button>
<button type="button" style={buttonStyle('quiet', true)} disabled>报告</button>
</div>
</section>
+11 -28
View File
@@ -8,9 +8,8 @@ import {
import { PanelLoadingBody } from './components/app/AppLoadingFallbacks';
const LazyDinsarProductionPanel = lazy(() => import('./DinsarProductionPanel'));
const LazyTimeseriesProductionPanel = lazy(() => import('./TimeseriesProductionPanel'));
const LazySbasInsarProductionPanel = lazy(() => import('./SbasInsarProductionPanel'));
const LazyDinsarProductsPanel = lazy(() => import('./DinsarProductsPanel'));
const LazyPsinsarCatalogPanel = lazy(() => import('./components/PsinsarCatalogPanel'));
const shellStyle = {
minHeight: '100%',
@@ -64,18 +63,10 @@ export default function ProductionWorkspace({
onTaskStart?.(taskId, 'D-InSAR 任务已入队,等待处理...');
};
const handleTimeseriesRunQueued = taskId => {
onTaskStart?.(taskId, '时序InSAR 运行已入队,当前默认执行 SBAS 流程...');
};
const handleDinsarProductQueued = taskId => {
onTaskStart?.(taskId, 'D-InSAR 产物任务已入队,等待处理...');
};
const handleTimeseriesProductQueued = taskId => {
onTaskStart?.(taskId, '时序InSAR 产物目录任务已入队,等待处理...');
};
return (
<div style={shellStyle}>
<div style={heroStyle}>
@@ -85,8 +76,8 @@ export default function ProductionWorkspace({
</div>
<h2 style={{ margin: '10px 0 12px', fontSize: 32, lineHeight: 1.1, color: '#0f172a' }}>生产管理</h2>
<p style={{ margin: 0, maxWidth: 900, fontSize: 14, lineHeight: 1.8, color: '#475569' }}>
这里统一承载 D-InSAR 与时序InSAR的运行和产物工作台。当前时序入口默认接入 SBAS 实现,
后续可在同一界面继续扩展 PS-InSAR、SBAS-InSAR 以及更多 WSL2 引擎实例。
这里统一承载 D-InSAR 与 Gamma SBAS-InSAR 生产工作台。旧 ISCE2/MintPy 时序入口已停用,
SBAS 生产、速率图、质量指标与监测点曲线统一进入独立 SBAS-InSAR 页面。
</p>
</section>
@@ -107,17 +98,17 @@ export default function ProductionWorkspace({
</div>
</div>
<div style={summaryCardStyle}>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>时序当前实现</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>SBAS 默认接入</div>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>SBAS 当前实现</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>Gamma 独立入口</div>
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#475569', marginTop: 4 }}>
现阶段实际运行链路为 SBAS,界面命名已统一为时序InSAR。
生产链路绕开旧时序配对层,由 SBAS 页面管理栈发现、基线审核、配准与产物发布。
</div>
</div>
<div style={summaryCardStyle}>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>引擎预留</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>ISCE / Gamma 可扩</div>
<div style={{ fontSize: 11, color: '#64748b', marginBottom: 6 }}>旧链路状态</div>
<div style={{ fontSize: 15, fontWeight: 700, color: '#0f172a' }}>ISCE2/MintPy 停用</div>
<div style={{ fontSize: 12, lineHeight: 1.6, color: '#475569', marginTop: 4 }}>
现有 WSL2 嵌入保持不变,后续增加 Gamma 时可直接挂入当前工作台。
历史代码暂时保留兼容,生产管理不再暴露旧“时序运行/产物”页面。
</div>
</div>
</section>
@@ -182,10 +173,9 @@ export default function ProductionWorkspace({
onJobQueued={handleDinsarRunQueued}
/>
)}
{activeView === 'timeseries_runs' && (
<LazyTimeseriesProductionPanel
{activeView === 'sbas_insar_production' && (
<LazySbasInsarProductionPanel
readOnly={readOnly}
onJobQueued={handleTimeseriesRunQueued}
/>
)}
{activeView === 'dinsar_products' && (
@@ -194,13 +184,6 @@ export default function ProductionWorkspace({
onJobQueued={handleDinsarProductQueued}
/>
)}
{activeView === 'timeseries_products' && (
<LazyPsinsarCatalogPanel
readOnly={readOnly}
showActions
onTaskQueued={handleTimeseriesProductQueued}
/>
)}
</Suspense>
</section>
</div>
File diff suppressed because it is too large Load Diff
+15 -39
View File
@@ -1,14 +1,5 @@
import apiClient from './client';
export const getFloodSources = (params = {}) =>
apiClient.get('/flood/sources', { params });
export const refreshFloodSources = () =>
apiClient.post('/flood/sources/refresh');
export const getFloodSourceReadiness = (id) =>
apiClient.get(`/flood/sources/${id}/readiness`);
export const submitFloodPreprocess = (payload) =>
apiClient.post('/flood/preprocess', payload);
@@ -33,26 +24,11 @@ export const getFloodWaterExtractions = (limit = 20, offset = 0, status = null)
export const getFloodWaterExtractionPreview = (id) =>
apiClient.get(`/flood/water-extractions/${id}/preview`).then(r => r.data);
export const getFloodPreprocessRuns = (params = {}) =>
apiClient.get('/flood/preprocess-runs', { params });
export const getFloodReadyProducts = (params = {}) =>
apiClient.get('/flood/ready-products', { params });
export const getFloodReadyProductPreview = (id) =>
apiClient.get(`/flood/ready-products/${id}/preview`).then(r => r.data);
export const searchFloodPairs = (payload) =>
apiClient.post('/flood/pairs/search', payload);
apiClient.post('/flood/pairs/search', payload).then(r => r.data);
export const saveFloodPair = (payload) =>
apiClient.post('/flood/pairs', payload);
export const getFloodPairs = (params = {}) =>
apiClient.get('/flood/pairs', { params });
export const deleteFloodPair = (id) =>
apiClient.delete(`/flood/pairs/${id}`);
export const searchFloodDisasterPairs = (payload) =>
apiClient.post('/flood/disaster-pairs/search', payload).then(r => r.data);
export const submitFloodDetection = (payload) =>
apiClient.post('/flood/detections', payload);
@@ -60,21 +36,27 @@ export const submitFloodDetection = (payload) =>
export const getFloodDetections = (params = {}) =>
apiClient.get('/flood/detections', { params });
export const getFloodDetection = (id) =>
apiClient.get(`/flood/detections/${id}`);
export const getFloodDetectionPreview = (id, layer) =>
apiClient.get(`/flood/detections/${id}/preview/${layer}`).then(r => r.data);
export const vectorizeFloodDetection = (id, payload = {}) =>
apiClient.post(`/flood/detections/${id}/vectorize`, payload);
export const runFloodOverlay = (id, payload = {}) =>
apiClient.post(`/flood/detections/${id}/overlay`, payload);
export const getFloodImpact = (id) =>
apiClient.get(`/flood/detections/${id}/impact`).then(r => r.data);
export const createFloodProduct = (detectionId) =>
apiClient.post(`/flood/detections/${detectionId}/products`);
export const getFloodProducts = (params = {}) =>
apiClient.get('/flood/products', { params });
export const getFloodProduct = (id) =>
apiClient.get(`/flood/products/${id}`);
export const getFloodProductManifest = (id) =>
apiClient.get(`/flood/products/${id}/manifest`).then(r => r.data);
export const getFloodResults = (params = {}) =>
apiClient.get('/flood/results', { params });
@@ -83,9 +65,3 @@ export const getFloodResult = (id) =>
export const getFloodResultManifest = (id) =>
apiClient.get(`/flood/results/${id}/manifest`).then(r => r.data);
export const createFloodReport = (payload) =>
apiClient.post('/flood/reports', payload);
export const getFloodReport = (id) =>
apiClient.get(`/flood/reports/${id}`).then(r => r.data);
+49
View File
@@ -0,0 +1,49 @@
import apiClient from './client';
const encodeArtifactPath = relativePath =>
String(relativePath || '')
.split('/')
.map(segment => encodeURIComponent(segment))
.join('/');
export const getSbasInsarCapabilities = () =>
apiClient.get('/sbas-insar-production/capabilities').then(r => r.data);
export const discoverSbasInsarStacks = (payload = {}) =>
apiClient.post('/sbas-insar-production/stacks/discover', payload).then(r => r.data);
export const auditSbasInsarStack = (stackId, payload = {}) =>
apiClient.post(`/sbas-insar-production/stacks/${encodeURIComponent(stackId)}/audit`, payload).then(r => r.data);
export const submitSbasInsarRun = (stackId, payload = {}) =>
apiClient.post(`/sbas-insar-production/stacks/${encodeURIComponent(stackId)}/runs`, payload).then(r => r.data);
export const listSbasInsarRuns = () =>
apiClient.get('/sbas-insar-production/runs').then(r => r.data);
export const getSbasInsarRun = runId =>
apiClient.get(`/sbas-insar-production/runs/${encodeURIComponent(runId)}`).then(r => r.data);
export const runSbasInsarBaselineAudit = (runId, payload = {}) =>
apiClient.post(`/sbas-insar-production/runs/${encodeURIComponent(runId)}/baseline-audit`, payload).then(r => r.data);
export const decideSbasInsarItab = (runId, payload = {}) =>
apiClient.post(`/sbas-insar-production/runs/${encodeURIComponent(runId)}/itab-decision`, payload).then(r => r.data);
export const prepareSbasInsarCoregistration = (runId, payload = {}) =>
apiClient.post(`/sbas-insar-production/runs/${encodeURIComponent(runId)}/coregistration`, payload).then(r => r.data);
export const submitSbasInsarCoregistrationJob = (runId, payload = {}) =>
apiClient.post(`/sbas-insar-production/runs/${encodeURIComponent(runId)}/coregistration/jobs`, payload).then(r => r.data);
export const getSbasInsarRunArtifactUrl = (runId, relativePath) =>
`/api/sbas-insar-production/runs/${encodeURIComponent(runId)}/artifacts/${encodeArtifactPath(relativePath)}`;
export const listSbasInsarTrialRuns = () =>
apiClient.get('/sbas-insar-production/trial-runs').then(r => r.data);
export const getSbasInsarTrialRun = trialId =>
apiClient.get(`/sbas-insar-production/trial-runs/${encodeURIComponent(trialId)}`).then(r => r.data);
export const getSbasInsarArtifactUrl = (trialId, relativePath) =>
`/api/sbas-insar-production/trial-runs/${encodeURIComponent(trialId)}/artifacts/${encodeArtifactPath(relativePath)}`;
@@ -22,6 +22,10 @@ const getTaskTypeLabel = (taskType) => {
return 'LT-1 解包';
case 'UNPACK_SENTINEL1':
return 'Sentinel-1 解包';
case 'GF3_UNPACK':
return 'GF3 解包';
case 'GF3_BATCH_PROCESS':
return 'GF3 预处理';
case 'SCAN_ASSET_INVENTORY':
return '资产库存扫描';
case 'IDL_IMPORT':
+1 -1
View File
@@ -110,7 +110,7 @@ export default function AppSidePanel({
? '生产管理'
: getLeftTabLabel(leftPanelTab, leftTabLabelContext);
const standaloneDescription = isProductionWorkspace
? '这里统一承载 D-InSAR 与时序InSAR的运行和产物页面,当前时序流程默认接入 SBAS,后续可继续扩展 PS-InSAR / SBAS-InSAR。'
? '这里统一承载 D-InSAR 与 Gamma SBAS-InSAR 生产;旧 ISCE2/MintPy 时序入口已停用。'
: '当前模块已切换为独立工作区模式。';
return (
+5 -10
View File
@@ -66,28 +66,23 @@ export const PRODUCTION_WORKSPACE_VIEWS = [
description: '运行任务编排、引擎切换与过程监控',
},
{
key: 'timeseries_runs',
label: '时序InSAR 运行',
description: '当前默认接入 SBAS 流程,后续可扩展 PS-InSAR / SBAS-InSAR',
key: 'sbas_insar_production',
label: 'SBAS-InSAR Production',
description: 'Gamma IPTA SBAS stack production, velocity maps, quality metrics, and monitor-point curves',
},
{
key: 'dinsar_products',
label: 'D-InSAR 产物',
description: '结果提取、标准目录发布与产物编目',
},
{
key: 'timeseries_products',
label: '时序InSAR 产物',
description: '当前统一登记时序产物,后续兼容多类型时序成果',
},
];
export const PRODUCTION_WORKSPACE_ENTRY_TO_VIEW = Object.freeze({
[PRODUCTION_WORKSPACE_TAB]: 'dinsar_runs',
dinsar_production: 'dinsar_runs',
dinsar_products: 'dinsar_products',
ps_production: 'timeseries_runs',
ps_products: 'timeseries_products',
ps_production: 'sbas_insar_production',
ps_products: 'sbas_insar_production',
});
export const PRODUCTION_WORKSPACE_ROUTE_TABS = new Set([
+7 -1
View File
@@ -11,7 +11,7 @@ import { normalizePagePayload } from '../utils/appHelpers';
import { normalizeTaskStatus } from '../utils/appUiHelpers';
import { DEFAULT_LIST_PAGE_SIZE } from '../config/appConstants';
const NON_BLOCKING_TASK_TYPES = new Set(['UNPACK_ARCHIVES', 'UNPACK_SENTINEL1', 'SCAN_ASSET_INVENTORY', 'COPY_DATA']);
const NON_BLOCKING_TASK_TYPES = new Set(['UNPACK_ARCHIVES', 'UNPACK_SENTINEL1', 'GF3_UNPACK', 'SCAN_ASSET_INVENTORY', 'COPY_DATA']);
export default function useDinsarOperations({
onCleanupDinsarLayers,
@@ -229,6 +229,12 @@ export default function useDinsarOperations({
} else if (taskStatus === 'FAILED') {
addLog('error', `LT-1 解包失败: ${taskInfo.message || '未知错误'}`);
}
} else if (taskInfo.task_type === 'GF3_UNPACK') {
if (taskStatus === 'COMPLETED') {
addLog('success', taskInfo.message || 'GF3 解包完成。');
} else if (taskStatus === 'FAILED') {
addLog('error', `GF3 解包失败: ${taskInfo.message || '未知错误'}`);
}
}
};
+1 -1
View File
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useRef, useState } from 'react';
import apiClient from '../api/client';
import { normalizeTaskStatus } from '../utils/appUiHelpers';
const NON_BLOCKING_TASK_TYPES = new Set(['UNPACK_ARCHIVES', 'UNPACK_SENTINEL1', 'SCAN_ASSET_INVENTORY', 'COPY_DATA']);
const NON_BLOCKING_TASK_TYPES = new Set(['UNPACK_ARCHIVES', 'UNPACK_SENTINEL1', 'GF3_UNPACK', 'SCAN_ASSET_INVENTORY', 'COPY_DATA']);
const isTaskNonBlocking = (taskId, taskType, nonBlockingTaskIds = []) => (
NON_BLOCKING_TASK_TYPES.has(String(taskType || '').toUpperCase())