Integrate GF3 SARscape flood workflow

This commit is contained in:
2026-06-02 01:25:42 +08:00
parent 16cac0e292
commit 79e08b3a47
24 changed files with 4220 additions and 84 deletions
+19
View File
@@ -207,7 +207,16 @@ class Settings(BaseSettings):
GF3_ARCHIVE_EXTS: str = ".zip,.tar,.tar.gz,.tgz"
GF3_UNPACK_DELETE_ARCHIVE: bool = True
GF3_SOURCE_DIRS: str = ""
GF3_SARSCAPE_NATIVE_DIRS: str = ""
GF3_STORAGE_DIRS: str = ""
GF3_SARSCAPE_WRAPPER_EXE: str = ""
GF3_SARSCAPE_IDLRT_PATH: str = r"C:\Program Files\Harris\ENVI56\IDL88\bin\bin.x86_64\idlrt.exe"
GF3_SARSCAPE_DEM_PATH: str = ""
GF3_SARSCAPE_POLARIZATIONS: str = "HH,HV"
GF3_SARSCAPE_KEEP_EXTRACTED: bool = True
GF3_SARSCAPE_AUTO_STANDARDIZE: bool = True
GF3_SARSCAPE_CLEAN_AFTER_SUCCESS: bool = True
GF3_SARSCAPE_PRODUCE_TIMEOUT_SECONDS: int = 0
MONITOR_ORBIT_DIR: str = ""
ORBIT_POOL_ENVI: str = ""
@@ -991,6 +1000,15 @@ def validate_runtime_config() -> dict[str, Any]:
_check_path(label="IDL_EXECUTABLE", value=settings.IDL_EXECUTABLE, errors=errors, warnings=warnings, expect_file=True)
_check_path(label="IDL_WORKBENCH_PATH", value=settings.IDL_WORKBENCH_PATH, errors=errors, warnings=warnings, expect_file=True)
_check_path(label="GF3_GEO_DEM_PATH", value=settings.GF3_GEO_DEM_PATH, errors=errors, warnings=warnings, expect_file=True)
_check_path(label="GF3_SARSCAPE_WRAPPER_EXE", value=settings.GF3_SARSCAPE_WRAPPER_EXE, errors=errors, warnings=warnings, expect_file=True)
_check_path(label="GF3_SARSCAPE_IDLRT_PATH", value=settings.GF3_SARSCAPE_IDLRT_PATH, errors=errors, warnings=warnings, expect_file=True)
_check_path(
label="GF3_SARSCAPE_DEM_PATH",
value=(settings.GF3_SARSCAPE_DEM_PATH or 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)
@@ -1024,6 +1042,7 @@ def validate_runtime_config() -> dict[str, Any]:
("MONITOR_DINSAR_DIRS", settings.MONITOR_DINSAR_DIRS),
("GF3_ARCHIVE_SOURCE_DIRS", settings.GF3_ARCHIVE_SOURCE_DIRS),
("GF3_SOURCE_DIRS", settings.GF3_SOURCE_DIRS),
("GF3_SARSCAPE_NATIVE_DIRS", settings.GF3_SARSCAPE_NATIVE_DIRS),
("GF3_STORAGE_DIRS", settings.GF3_STORAGE_DIRS),
):
values = split_env_paths(raw_value)
+6 -6
View File
@@ -210,12 +210,12 @@ class RadarData(BaseModel):
has_orbit_data: bool
orbit_file_path: Optional[str] = None
is_envi_processed: bool = False
coverage_polygon: List[Tuple[float, float]]
coverage_polygon: Optional[List[Tuple[float, float]]] = None
min_lon: float
min_lat: float
max_lon: float
max_lat: float
min_lon: Optional[float] = None
min_lat: Optional[float] = None
max_lon: Optional[float] = None
max_lat: Optional[float] = None
preview_cache_status: str = "NONE"
preview_cache_version: Optional[str] = None
preview_cache_updated_at: Optional[datetime] = None
@@ -236,7 +236,7 @@ class RadarData(BaseModel):
@computed_field
@property
def coverage_bbox(self) -> Tuple[float, float, float, float]:
def coverage_bbox(self) -> Tuple[Optional[float], Optional[float], Optional[float], Optional[float]]:
"""A computed property to provide the bbox tuple, used by existing logic."""
return (self.min_lon, self.min_lat, self.max_lon, self.max_lat)
+159
View File
@@ -43,7 +43,14 @@ class MonitorConfig(BaseModel):
dinsar_dirs: List[str] = []
gf3_archive_source_dirs: List[str] = []
gf3_source_dirs: List[str] = []
gf3_sarscape_native_dirs: List[str] = []
gf3_storage_dirs: List[str] = []
gf3_sarscape_wrapper_exe: Optional[str] = None
gf3_sarscape_idlrt_path: Optional[str] = None
gf3_sarscape_dem_path: Optional[str] = None
gf3_sarscape_polarizations: Optional[str] = None
gf3_sarscape_auto_standardize: bool = True
gf3_sarscape_clean_after_success: bool = True
# Manual-only: config is read from .env
@@ -58,6 +65,26 @@ class GF3UnpackRunRequest(BaseModel):
max_files_per_run: Optional[int] = Field(default=None, ge=0)
class GF3SarscapeSyncRequest(BaseModel):
force: bool = False
register: bool = True
class GF3SarscapeProduceRequest(BaseModel):
max_archives_per_run: Optional[int] = Field(default=None, ge=0)
auto_standardize: Optional[bool] = None
clean_after_success: Optional[bool] = None
force_standardize: bool = False
register: bool = True
cleanup_dry_run: bool = False
class GF3SarscapeCleanRequest(BaseModel):
dry_run: bool = False
require_standardized: bool = True
max_scenes: Optional[int] = Field(default=None, ge=0)
@router.post("/monitor/config")
async def update_monitor_config(config: MonitorConfig):
"""
@@ -154,6 +181,138 @@ async def run_gf3_batch_process(admin_user: AuthUserORM = Depends(_require_admin
raise HTTPException(status_code=409, detail=str(e))
@router.post("/monitor/gf3-sarscape-sync", status_code=202)
async def run_gf3_sarscape_sync(
request_data: GF3SarscapeSyncRequest | None = None,
admin_user: AuthUserORM = Depends(_require_admin),
):
"""
扫描 GF3 SARscape 原生 _geo 二进制池,转换为标准 GeoTIFF,并登记入库。
"""
gf3_native_dirs = MONITOR_CONFIG.get("gf3_sarscape_native_dirs") or []
gf3_storage_dirs = MONITOR_CONFIG.get("gf3_storage_dirs") or []
if not gf3_native_dirs:
raise HTTPException(status_code=400, detail="GF3_SARSCAPE_NATIVE_DIRS is not configured.")
if not gf3_storage_dirs:
raise HTTPException(status_code=400, detail="GF3_STORAGE_DIRS is not configured.")
options = request_data or GF3SarscapeSyncRequest()
task_type = "GF3_SARSCAPE_SYNC"
task_name = "GF3 SARscape 原生结果标准化"
payload = {
"native_dirs": gf3_native_dirs,
"storage_root": gf3_storage_dirs[0],
"force": bool(options.force),
"register": bool(options.register),
}
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 SARscape 原生结果标准化任务已提交",
"task_id": task_id,
}
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
@router.post("/monitor/gf3-sarscape-produce", status_code=202)
async def run_gf3_sarscape_produce(
request_data: GF3SarscapeProduceRequest | None = None,
admin_user: AuthUserORM = Depends(_require_admin),
):
"""
Run GF3 raw archives through the SARscape wrapper, standardize outputs, and optionally clean intermediates.
"""
gf3_archive_source_dirs = MONITOR_CONFIG.get("gf3_archive_source_dirs") or []
gf3_native_dirs = MONITOR_CONFIG.get("gf3_sarscape_native_dirs") or []
gf3_storage_dirs = MONITOR_CONFIG.get("gf3_storage_dirs") or []
if not gf3_archive_source_dirs:
raise HTTPException(status_code=400, detail="GF3_ARCHIVE_SOURCE_DIRS is not configured.")
if not gf3_native_dirs:
raise HTTPException(status_code=400, detail="GF3_SARSCAPE_NATIVE_DIRS is not configured.")
if not gf3_storage_dirs:
raise HTTPException(status_code=400, detail="GF3_STORAGE_DIRS is not configured.")
if not settings.GF3_SARSCAPE_WRAPPER_EXE:
raise HTTPException(status_code=400, detail="GF3_SARSCAPE_WRAPPER_EXE is not configured.")
if not (settings.GF3_SARSCAPE_DEM_PATH or settings.GF3_GEO_DEM_PATH):
raise HTTPException(status_code=400, detail="GF3_SARSCAPE_DEM_PATH or GF3_GEO_DEM_PATH is not configured.")
options = request_data or GF3SarscapeProduceRequest()
task_type = "GF3_SARSCAPE_PRODUCE"
task_name = "GF3 SARscape production"
auto_standardize = settings.GF3_SARSCAPE_AUTO_STANDARDIZE if options.auto_standardize is None else bool(options.auto_standardize)
clean_after_success = settings.GF3_SARSCAPE_CLEAN_AFTER_SUCCESS if options.clean_after_success is None else bool(options.clean_after_success)
payload = {
"source_dirs": gf3_archive_source_dirs,
"native_dirs": gf3_native_dirs,
"native_root": gf3_native_dirs[0],
"storage_root": gf3_storage_dirs[0],
"wrapper_exe": settings.GF3_SARSCAPE_WRAPPER_EXE,
"idlrt_path": settings.GF3_SARSCAPE_IDLRT_PATH,
"dem_path": settings.GF3_SARSCAPE_DEM_PATH or settings.GF3_GEO_DEM_PATH,
"polarizations": settings.GF3_SARSCAPE_POLARIZATIONS,
"archive_exts": split_env_paths(settings.GF3_ARCHIVE_EXTS),
"max_archives_per_run": int(options.max_archives_per_run or 0),
"timeout_seconds": int(settings.GF3_SARSCAPE_PRODUCE_TIMEOUT_SECONDS or 0),
"keep_extracted": bool(settings.GF3_SARSCAPE_KEEP_EXTRACTED),
"auto_standardize": bool(auto_standardize),
"clean_after_success": bool(clean_after_success),
"force_standardize": bool(options.force_standardize),
"register": bool(options.register),
"cleanup_require_standardized": True,
"cleanup_dry_run": bool(options.cleanup_dry_run),
}
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 SARscape production task submitted",
"task_id": task_id,
}
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
@router.post("/monitor/gf3-sarscape-clean", status_code=202)
async def run_gf3_sarscape_clean(
request_data: GF3SarscapeCleanRequest | None = None,
admin_user: AuthUserORM = Depends(_require_admin),
):
"""
Clean SARscape intermediate files from GF3 native pool after standard GeoTIFFs exist.
"""
gf3_native_dirs = MONITOR_CONFIG.get("gf3_sarscape_native_dirs") or []
gf3_storage_dirs = MONITOR_CONFIG.get("gf3_storage_dirs") or []
if not gf3_native_dirs:
raise HTTPException(status_code=400, detail="GF3_SARSCAPE_NATIVE_DIRS is not configured.")
if not gf3_storage_dirs:
raise HTTPException(status_code=400, detail="GF3_STORAGE_DIRS is not configured.")
options = request_data or GF3SarscapeCleanRequest()
task_type = "GF3_SARSCAPE_CLEAN"
task_name = "GF3 SARscape native cleanup"
payload = {
"native_dirs": gf3_native_dirs,
"storage_root": gf3_storage_dirs[0],
"dry_run": bool(options.dry_run),
"require_standardized": bool(options.require_standardized),
"max_scenes": int(options.max_scenes or 0),
}
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 SARscape cleanup task submitted",
"task_id": task_id,
}
except ValueError as e:
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(
+7
View File
@@ -21,7 +21,14 @@ MONITOR_CONFIG = {
# GF3 链路
"gf3_archive_source_dirs": split_env_paths(settings.GF3_ARCHIVE_SOURCE_DIRS),
"gf3_source_dirs": split_env_paths(settings.GF3_SOURCE_DIRS),
"gf3_sarscape_native_dirs": split_env_paths(settings.GF3_SARSCAPE_NATIVE_DIRS),
"gf3_storage_dirs": split_env_paths(settings.GF3_STORAGE_DIRS),
"gf3_sarscape_wrapper_exe": settings.GF3_SARSCAPE_WRAPPER_EXE,
"gf3_sarscape_idlrt_path": settings.GF3_SARSCAPE_IDLRT_PATH,
"gf3_sarscape_dem_path": settings.GF3_SARSCAPE_DEM_PATH or settings.GF3_GEO_DEM_PATH,
"gf3_sarscape_polarizations": settings.GF3_SARSCAPE_POLARIZATIONS,
"gf3_sarscape_auto_standardize": bool(settings.GF3_SARSCAPE_AUTO_STANDARDIZE),
"gf3_sarscape_clean_after_success": bool(settings.GF3_SARSCAPE_CLEAN_AFTER_SUCCESS),
"mode": "manual",
"config_source": "env",
}
@@ -0,0 +1,308 @@
"""Inventory GF3 SARscape native geocoded outputs.
The production server writes ENVI/SARscape native ``*_geo`` datasets. This
service treats those files as the source-of-truth evidence layer and produces a
small manifest that later conversion jobs can consume.
"""
from __future__ import annotations
import json
import os
import re
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from ..utils import parse_gf3_l2_dirname
NATIVE_MANIFEST_NAME = "gf3_native_manifest.json"
NATIVE_MANIFEST_SCHEMA = "gf3_sarscape_native.v1"
POLARIZATION_PRIORITY = ("HH", "VV", "HV", "VH")
SKIP_DIR_NAMES = {
".git",
".gf3_extract",
".sarmap",
"__pycache__",
"temp",
"tmp",
"work",
"sarscape_work",
"GTOPO30_DIR",
"SRTM_DEM_DIR",
"TANDEMX_DEM_DIR",
}
def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _safe_stat(path: Path) -> dict[str, Any] | None:
try:
stat = path.stat()
except OSError:
return None
return {
"path": str(path),
"size": int(stat.st_size),
"mtime": float(stat.st_mtime),
"mtime_ns": int(stat.st_mtime_ns),
}
def _is_nonempty_file(path: Path) -> bool:
try:
return path.is_file() and path.stat().st_size > 0
except OSError:
return False
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f".{path.name}.tmp")
with tmp_path.open("w", encoding="utf-8") as stream:
json.dump(payload, stream, ensure_ascii=False, indent=2, default=str)
os.replace(tmp_path, path)
def _extract_date_from_text(value: str) -> str | None:
match = re.search(r"(20\d{6})", value or "")
return match.group(1) if match else None
def _extract_product_unique_id(value: str) -> str | None:
match = re.search(r"(L\d{8,})", value or "", flags=re.IGNORECASE)
return match.group(1).upper() if match else None
def _polarization_from_geo_name(name: str) -> str | None:
upper_name = name.upper()
match = re.search(r"(?:^|[_-])(HH|HV|VH|VV)[_-]GEO$", upper_name)
if match:
return match.group(1)
tokens = [token for token in re.split(r"[_\-.]+", upper_name) if token]
for token in reversed(tokens):
if token in POLARIZATION_PRIORITY:
return token
return None
def _is_geo_native_data_file(path: Path) -> bool:
return path.is_file() and path.name.lower().endswith("_geo")
def _scene_batch_name(root: Path, scene_dir: Path, scene_name: str) -> str | None:
try:
rel_parts = scene_dir.relative_to(root).parts
except ValueError:
rel_parts = ()
if len(rel_parts) >= 2:
return rel_parts[0]
date = _extract_date_from_text(scene_name)
return date
def _parse_scene_metadata(scene_name: str, assets: list[dict[str, Any]]) -> dict[str, Any]:
parsed = parse_gf3_l2_dirname(scene_name) or {}
metadata: dict[str, Any] = {
"satellite": "GF3",
"satellite_family": "GF3",
**parsed,
}
if not metadata.get("imaging_date"):
metadata["imaging_date"] = _extract_date_from_text(scene_name)
if not metadata.get("product_unique_id"):
metadata["product_unique_id"] = _extract_product_unique_id(scene_name)
polarizations = [
str(asset.get("polarization") or "").upper()
for asset in assets
if asset.get("polarization") and asset.get("polarization") != "UNKNOWN"
]
if polarizations:
metadata["polarization"] = ",".join(
pol for pol in POLARIZATION_PRIORITY if pol in set(polarizations)
) or ",".join(sorted(set(polarizations)))
metadata["product_level"] = "L2"
metadata["source_format"] = "GF3_SARSCAPE_NATIVE"
return metadata
def _collect_native_assets(scene_dir: Path) -> list[dict[str, Any]]:
assets: list[dict[str, Any]] = []
try:
entries = sorted(scene_dir.iterdir(), key=lambda item: item.name.lower())
except OSError:
return assets
for path in entries:
if not _is_geo_native_data_file(path):
continue
base = path
hdr = Path(str(base) + ".hdr")
sml = Path(str(base) + ".sml")
aux_xml = Path(str(base) + ".aux.xml")
ovr = Path(str(base) + ".ovr")
kml = Path(str(base) + ".kml")
quicklook = base.with_name(base.name + "_ql.tif")
polarization = _polarization_from_geo_name(base.name) or "UNKNOWN"
complete = _is_nonempty_file(base) and _is_nonempty_file(hdr) and _is_nonempty_file(sml)
asset: dict[str, Any] = {
"polarization": polarization,
"role": "geo_native",
"path": str(base),
"hdr": str(hdr) if hdr.exists() else None,
"sml": str(sml) if sml.exists() else None,
"aux_xml": str(aux_xml) if aux_xml.exists() else None,
"ovr": str(ovr) if ovr.exists() else None,
"quicklook": str(quicklook) if quicklook.exists() else None,
"kml": str(kml) if kml.exists() else None,
"complete": bool(complete),
"source": _safe_stat(base),
"hdr_info": _safe_stat(hdr) if hdr.exists() else None,
"sml_info": _safe_stat(sml) if sml.exists() else None,
}
assets.append(asset)
return assets
def _collect_scene_manifest(root: Path, scene_dir: Path) -> dict[str, Any] | None:
assets = _collect_native_assets(scene_dir)
if not assets:
return None
scene_name = scene_dir.name
batch_name = _scene_batch_name(root, scene_dir, scene_name)
complete_assets = [asset for asset in assets if asset.get("complete")]
complete_pols = [
pol
for pol in POLARIZATION_PRIORITY
if any(asset.get("complete") and asset.get("polarization") == pol for asset in assets)
]
other_complete_pols = sorted(
{
str(asset.get("polarization") or "")
for asset in complete_assets
if asset.get("polarization") not in POLARIZATION_PRIORITY
}
)
complete_pols.extend([pol for pol in other_complete_pols if pol])
if complete_assets and len(complete_assets) == len(assets):
status = "NATIVE_READY"
elif complete_assets:
status = "PARTIAL"
else:
status = "FAILED"
logs = []
for name in ("gf3_sarscape_cli.log",):
log_path = scene_dir / name
if log_path.is_file():
logs.append(str(log_path))
try:
logs.extend(str(path) for path in sorted(scene_dir.glob("*.log"), key=lambda item: item.name.lower()) if str(path) not in logs)
except OSError:
pass
metadata = _parse_scene_metadata(scene_name, assets)
manifest_path = scene_dir / NATIVE_MANIFEST_NAME
return {
"schema": NATIVE_MANIFEST_SCHEMA,
"generated_at": _utc_now(),
"scene_name": scene_name,
"batch_name": batch_name,
"native_root": str(root),
"native_dir": str(scene_dir),
"manifest_path": str(manifest_path),
"source_archive": None,
"status": status,
"polarizations": complete_pols,
"metadata": metadata,
"assets": assets,
"logs": logs,
}
def _normalize_roots(native_dirs: list[str] | tuple[str, ...] | None) -> tuple[list[Path], list[str]]:
roots: list[Path] = []
missing: list[str] = []
seen: set[str] = set()
for raw in native_dirs or []:
text = str(raw or "").strip()
if not text:
continue
path = Path(os.path.normpath(text)).resolve()
key = str(path).lower()
if key in seen:
continue
seen.add(key)
if path.is_dir():
roots.append(path)
else:
missing.append(str(path))
return roots, missing
def scan_gf3_sarscape_native_roots(
native_dirs: list[str] | tuple[str, ...] | None,
*,
write_manifest: bool = True,
) -> dict[str, Any]:
"""Scan configured native roots and return discovered scene manifests."""
roots, missing_roots = _normalize_roots(native_dirs)
scenes: list[dict[str, Any]] = []
seen_scene_dirs: set[str] = set()
write_errors: list[dict[str, str]] = []
for root in roots:
for current_dir, dir_names, _file_names in os.walk(root):
dir_names[:] = [
name
for name in dir_names
if name not in SKIP_DIR_NAMES and not name.startswith(".SARscape")
]
scene_dir = Path(current_dir)
scene_key = str(scene_dir).lower()
if scene_key in seen_scene_dirs:
dir_names[:] = []
continue
manifest = _collect_scene_manifest(root, scene_dir)
if not manifest:
continue
seen_scene_dirs.add(scene_key)
if write_manifest:
try:
_write_json(Path(manifest["manifest_path"]), manifest)
except OSError as exc:
write_errors.append({"scene_dir": str(scene_dir), "error": str(exc)})
scenes.append(manifest)
dir_names[:] = []
native_ready = sum(1 for scene in scenes if scene.get("status") == "NATIVE_READY")
partial = sum(1 for scene in scenes if scene.get("status") == "PARTIAL")
failed = sum(1 for scene in scenes if scene.get("status") == "FAILED")
complete_assets = sum(
1
for scene in scenes
for asset in scene.get("assets") or []
if asset.get("complete")
)
return {
"schema": "gf3_sarscape_native_inventory.v1",
"generated_at": _utc_now(),
"native_roots": [str(path) for path in roots],
"missing_roots": missing_roots,
"scene_count": len(scenes),
"native_ready_count": native_ready,
"partial_count": partial,
"failed_count": failed,
"complete_asset_count": complete_assets,
"write_errors": write_errors,
"scenes": scenes,
}
@@ -0,0 +1,843 @@
"""Run GF3 SARscape production and clean native intermediate files."""
from __future__ import annotations
import json
import os
import re
import shutil
import subprocess
import time
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable
from ..config import settings, split_env_paths
from .gf3_native_inventory_service import (
NATIVE_MANIFEST_NAME,
POLARIZATION_PRIORITY,
scan_gf3_sarscape_native_roots,
)
from .gf3_standardize_service import STANDARD_MANIFEST_NAME
LogCallback = Callable[[str, str], None]
ProgressCallback = Callable[[int, str], None]
SUPPORTED_WRAPPER_INPUT_EXTS = (".tar.gz", ".tgz", ".meta.xml")
CLEANUP_MANIFEST_NAME = "gf3_cleanup_manifest.json"
INTERMEDIATE_DIR_NAMES = {
".gf3_extract",
".gf3_extract.tmp",
"temp",
"tmp",
"work",
}
KEEP_FILE_NAMES = {
NATIVE_MANIFEST_NAME,
CLEANUP_MANIFEST_NAME,
"gf3_sarscape_cli.log",
}
INTERMEDIATE_SUFFIXES = (
".par",
".par_command",
".trace",
".working",
".working_warning",
".workinggetcornerfromslantrangeimage_dem",
".txt",
".list",
".listhv",
".listunknown",
".shp",
".shx",
".dbf",
".prj",
)
def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _clean_text(value: Any) -> str:
return str(value or "").strip().strip('"').strip("'")
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f".{path.name}.tmp")
with tmp_path.open("w", encoding="utf-8") as stream:
json.dump(payload, stream, ensure_ascii=False, indent=2, default=str)
os.replace(tmp_path, path)
def _read_json(path: Path) -> dict[str, Any] | None:
try:
with path.open("r", encoding="utf-8") as stream:
data = json.load(stream)
return data if isinstance(data, dict) else None
except (OSError, json.JSONDecodeError):
return None
def _safe_slug(value: Any, *, default: str = "unknown") -> str:
text = _clean_text(value) or default
safe = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in text).strip("._-")
return safe or default
def _resolve_existing_dirs(values: list[str] | tuple[str, ...] | None) -> tuple[list[Path], list[str]]:
roots: list[Path] = []
missing: list[str] = []
seen: set[str] = set()
for raw in values or []:
text = _clean_text(raw)
if not text:
continue
path = Path(os.path.normpath(text)).resolve()
key = str(path).lower()
if key in seen:
continue
seen.add(key)
if path.is_dir():
roots.append(path)
else:
missing.append(str(path))
return roots, missing
def _resolve_config_path(raw: Any) -> Path | None:
text = _clean_text(raw)
if not text:
return None
return Path(os.path.normpath(text)).resolve()
def _fallback_wrapper_exe() -> Path | None:
candidate = (
Path(settings.PROJECT_ROOT)
/ ".codex_tmp"
/ "GF3_L1A_To_L2_pipeline"
/ "dist"
/ "windows"
/ "gf3wrapper.exe"
)
return candidate.resolve() if candidate.is_file() else None
def _wrapper_exe_path(value: str | None = None) -> Path:
configured = _resolve_config_path(value or settings.GF3_SARSCAPE_WRAPPER_EXE)
path = configured or _fallback_wrapper_exe()
if path is None:
raise FileNotFoundError("GF3 SARscape wrapper is not configured.")
if not path.is_file():
raise FileNotFoundError(f"GF3 SARscape wrapper does not exist: {path}")
return path
def _dem_path(value: str | None = None) -> Path:
path = _resolve_config_path(value or settings.GF3_SARSCAPE_DEM_PATH or settings.GF3_GEO_DEM_PATH)
if path is None:
raise FileNotFoundError("GF3 SARscape DEM path is not configured.")
if not path.exists():
raise FileNotFoundError(f"GF3 SARscape DEM path does not exist: {path}")
return path
def _idlrt_path(value: str | None = None) -> Path | None:
path = _resolve_config_path(value or settings.GF3_SARSCAPE_IDLRT_PATH)
if path is None:
return None
if not path.is_file():
raise FileNotFoundError(f"GF3 SARscape idlrt.exe does not exist: {path}")
return path
def _requested_polarizations(value: str | None = None) -> list[str]:
raw = _clean_text(value or settings.GF3_SARSCAPE_POLARIZATIONS or "HH,HV")
items: list[str] = []
for token in re.split(r"[,;\s]+", raw):
pol = token.strip().upper()
if not pol:
continue
if pol not in items:
items.append(pol)
return items or ["HH", "HV"]
def _archive_exts_for_wrapper(archive_exts: list[str] | tuple[str, ...] | None) -> list[str]:
ordered: list[str] = []
for raw_ext in archive_exts or []:
ext = _clean_text(raw_ext).lower()
if not ext:
continue
if not ext.startswith("."):
ext = "." + ext
if ext in SUPPORTED_WRAPPER_INPUT_EXTS and ext not in ordered:
ordered.append(ext)
if not ordered:
ordered.extend((".tar.gz", ".tgz"))
return sorted(ordered, key=len, reverse=True)
def _input_ext(path: Path, exts: list[str]) -> str | None:
name = path.name.lower()
for ext in exts:
if name.endswith(ext):
return ext
return None
def _scene_name_from_input(path: Path) -> str:
name = path.name
lower_name = name.lower()
for ext in SUPPORTED_WRAPPER_INPUT_EXTS:
if lower_name.endswith(ext):
return name[: -len(ext)]
return path.stem
def discover_gf3_sarscape_inputs(
source_dirs: list[str] | tuple[str, ...] | None,
*,
archive_exts: list[str] | tuple[str, ...] | None = None,
) -> dict[str, Any]:
"""Find wrapper-supported GF3 source inputs in configured archive pools."""
roots, missing_roots = _resolve_existing_dirs(source_dirs)
exts = _archive_exts_for_wrapper(archive_exts)
inputs: list[dict[str, Any]] = []
seen: set[str] = set()
for root in roots:
for path in root.rglob("*"):
if not path.is_file():
continue
ext = _input_ext(path, exts)
if not ext:
continue
resolved = path.resolve()
key = str(resolved).lower()
if key in seen:
continue
seen.add(key)
inputs.append(
{
"path": str(resolved),
"scene_name": _scene_name_from_input(path),
"ext": ext,
"source_root": str(root),
}
)
inputs.sort(key=lambda item: str(item.get("path") or "").lower())
return {
"source_roots": [str(path) for path in roots],
"missing_roots": missing_roots,
"archive_exts": exts,
"input_count": len(inputs),
"inputs": inputs,
}
def _is_nonempty_file(path: Path) -> bool:
try:
return path.is_file() and path.stat().st_size > 0
except OSError:
return False
def _completed_geo_product(scene_dir: Path, polarization: str) -> Path | None:
lower_pol = polarization.lower()
try:
entries = list(scene_dir.iterdir())
except OSError:
return None
for path in entries:
name = path.name.lower()
if not name.endswith(f"_{lower_pol}_geo.sml"):
continue
data_file = Path(str(path)[: -len(".sml")])
if _is_nonempty_file(path) and _is_nonempty_file(data_file):
return data_file
return None
def _scene_complete(scene_dir: Path, polarizations: list[str]) -> bool:
if not scene_dir.is_dir():
return False
return all(_completed_geo_product(scene_dir, pol) is not None for pol in polarizations)
def _missing_geo_polarizations(scene_dir: Path, polarizations: list[str]) -> list[str]:
if not scene_dir.is_dir():
return list(polarizations)
return [pol for pol in polarizations if _completed_geo_product(scene_dir, pol) is None]
def _compact_failure_text(text: str, *, max_chars: int = 1000) -> str:
lines = [line.strip() for line in text.splitlines() if line.strip()]
compact = " | ".join(lines)
if len(compact) <= max_chars:
return compact
return compact[: max_chars - 3].rstrip() + "..."
def _read_failure_file(path: Path) -> str | None:
try:
if not path.is_file() or path.stat().st_size <= 0:
return None
return path.read_text(encoding="utf-8", errors="replace")
except OSError:
return None
def _scene_failure_hint(scene_dir: Path) -> str | None:
work_dir = scene_dir / "work"
candidates = [
work_dir / "Process.working_error",
work_dir / "Process.trace_cerr.txt",
scene_dir / "gf3_sarscape_cli.log",
]
for path in candidates:
text = _read_failure_file(path)
if text:
compact = _compact_failure_text(text)
if compact:
return f"{path.name}: {compact}"
process_log = work_dir / "Process.log"
text = _read_failure_file(process_log)
if not text:
return None
important = [
line.strip()
for line in text.splitlines()
if "ERROR" in line.upper() or "[EC:" in line.upper() or "PARAMETER FILE READ ERROR" in line.upper()
]
if important:
return f"{process_log.name}: {_compact_failure_text(chr(10).join(important[-8:]))}"
return None
def _format_missing_output_error(scene_dir: Path, polarizations: list[str], returncode: int) -> str:
missing = _missing_geo_polarizations(scene_dir, polarizations)
missing_text = ",".join(missing) if missing else "unknown"
if returncode == 0:
base = f"wrapper returned 0 but required _geo outputs are missing: {missing_text}"
else:
base = f"wrapper return code {returncode}; missing _geo outputs: {missing_text}"
hint = _scene_failure_hint(scene_dir)
return f"{base}; {hint}" if hint else base
def _emit_log(callback: LogCallback | None, level: str, message: str) -> None:
if callback:
callback(level, message)
def _emit_progress(callback: ProgressCallback | None, progress: int, message: str) -> None:
if callback:
callback(max(0, min(100, int(progress))), message)
def _run_wrapper_command(
cmd: list[str],
*,
cwd: Path,
env: dict[str, str],
timeout_seconds: int | None,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
cmd,
cwd=str(cwd),
env=env,
capture_output=True,
text=True,
encoding="utf-8",
errors="replace",
timeout=timeout_seconds if timeout_seconds and timeout_seconds > 0 else None,
check=False,
)
def _log_completed_process_output(
completed: subprocess.CompletedProcess[str],
*,
log_callback: LogCallback | None,
line_limit: int = 300,
) -> list[str]:
output = completed.stdout or ""
lines = [line.rstrip() for line in output.splitlines() if line.strip()]
if not lines:
return []
clipped = False
display_lines = lines
if len(lines) > line_limit:
clipped = True
head_count = max(1, line_limit // 2)
tail_count = max(1, line_limit - head_count)
display_lines = lines[:head_count] + [f"... clipped {len(lines) - line_limit} wrapper log lines ..."] + lines[-tail_count:]
for line in display_lines:
_emit_log(log_callback, "INFO", f"[gf3wrapper] {line}")
if clipped:
_emit_log(log_callback, "WARNING", f"GF3 wrapper output was clipped to {line_limit} log lines.")
return lines[-20:]
def run_gf3_sarscape_production(
*,
source_dirs: list[str] | None = None,
native_root: str | None = None,
wrapper_exe: str | None = None,
dem_path: str | None = None,
idlrt_path: str | None = None,
polarizations: str | None = None,
archive_exts: list[str] | None = None,
max_archives_per_run: int | None = None,
timeout_seconds: int | None = None,
keep_extracted: bool | None = None,
log_callback: LogCallback | None = None,
progress_callback: ProgressCallback | None = None,
) -> dict[str, Any]:
"""Run the external GF3 SARscape wrapper for pending raw archives."""
source_dirs = source_dirs if source_dirs is not None else split_env_paths(settings.GF3_ARCHIVE_SOURCE_DIRS)
native_roots = split_env_paths(settings.GF3_SARSCAPE_NATIVE_DIRS)
native_root_text = _clean_text(native_root or (native_roots[0] if native_roots else ""))
if not source_dirs:
raise ValueError("GF3_ARCHIVE_SOURCE_DIRS is not configured.")
if not native_root_text:
raise ValueError("GF3_SARSCAPE_NATIVE_DIRS is not configured.")
native_root_path = Path(os.path.normpath(native_root_text)).resolve()
native_root_path.mkdir(parents=True, exist_ok=True)
wrapper_path = _wrapper_exe_path(wrapper_exe)
dem = _dem_path(dem_path)
idlrt = _idlrt_path(idlrt_path)
pols = _requested_polarizations(polarizations)
pol_text = ",".join(pols)
ext_config = archive_exts if archive_exts is not None else split_env_paths(settings.GF3_ARCHIVE_EXTS)
discovery = discover_gf3_sarscape_inputs(source_dirs, archive_exts=ext_config)
inputs = discovery.get("inputs") or []
max_to_process = int(max_archives_per_run or 0)
timeout = int(timeout_seconds or 0)
keep = bool(settings.GF3_SARSCAPE_KEEP_EXTRACTED if keep_extracted is None else keep_extracted)
_emit_log(log_callback, "INFO", f"GF3 SARscape source roots: {source_dirs}")
_emit_log(log_callback, "INFO", f"GF3 SARscape native root: {native_root_path}")
_emit_log(log_callback, "INFO", f"GF3 SARscape wrapper: {wrapper_path}")
_emit_log(log_callback, "INFO", f"GF3 SARscape DEM: {dem}")
_emit_log(log_callback, "INFO", f"GF3 SARscape polarizations: {pol_text}")
if not inputs:
_emit_progress(progress_callback, 100, "GF3 SARscape production found no supported inputs.")
return {
"ok": True,
"found_count": 0,
"processed_count": 0,
"skipped_count": 0,
"failed_count": 0,
"deferred_count": 0,
"missing_roots": discovery.get("missing_roots") or [],
"results": [],
}
runtime_dir = native_root_path / ".gf3_runtime"
runtime_dir.mkdir(parents=True, exist_ok=True)
config_path = runtime_dir / "gf3wrapper.json"
env = os.environ.copy()
if idlrt is not None:
env["IDLRT_PATH"] = str(idlrt)
processed = 0
skipped = 0
failed = 0
deferred = 0
results: list[dict[str, Any]] = []
total = len(inputs)
for idx, input_info in enumerate(inputs):
input_path = Path(str(input_info.get("path") or "")).resolve()
scene_name = str(input_info.get("scene_name") or _scene_name_from_input(input_path))
scene_dir = native_root_path / scene_name
progress = 5 + int((idx / max(total, 1)) * 60)
_emit_progress(progress_callback, progress, f"GF3 SARscape checking {idx + 1}/{total}: {scene_name}")
if _scene_complete(scene_dir, pols):
skipped += 1
results.append(
{
"scene_name": scene_name,
"input_path": str(input_path),
"scene_dir": str(scene_dir),
"status": "skipped_complete",
}
)
continue
if max_to_process > 0 and processed + failed >= max_to_process:
deferred += 1
results.append(
{
"scene_name": scene_name,
"input_path": str(input_path),
"scene_dir": str(scene_dir),
"status": "deferred",
}
)
continue
cmd = [
str(wrapper_path),
"-config",
str(config_path),
"-input",
str(input_path),
"-output",
str(native_root_path),
"-dem",
str(dem),
"-pol",
pol_text,
f"-keep-extracted={str(keep).lower()}",
]
if idlrt is not None:
cmd.extend(["-idlrt", str(idlrt)])
_emit_log(log_callback, "INFO", f"GF3 SARscape processing {scene_name}: {input_path}")
started = time.monotonic()
try:
completed = _run_wrapper_command(
cmd,
cwd=wrapper_path.parent,
env=env,
timeout_seconds=timeout,
)
output_tail = _log_completed_process_output(completed, log_callback=log_callback)
elapsed_seconds = round(time.monotonic() - started, 3)
output_complete = _scene_complete(scene_dir, pols)
if completed.returncode == 0 and output_complete:
processed += 1
status = "processed"
error = None
elif output_complete:
processed += 1
status = "processed_with_warning"
error = f"wrapper return code {completed.returncode}"
_emit_log(log_callback, "WARNING", f"GF3 wrapper returned {completed.returncode}, but output is complete: {scene_name}")
else:
failed += 1
status = "failed"
error = _format_missing_output_error(scene_dir, pols, int(completed.returncode))
_emit_log(log_callback, "ERROR", f"GF3 SARscape failed for {scene_name}: {error}")
results.append(
{
"scene_name": scene_name,
"input_path": str(input_path),
"scene_dir": str(scene_dir),
"status": status,
"returncode": int(completed.returncode),
"elapsed_seconds": elapsed_seconds,
"output_complete": output_complete,
"error": error,
"output_tail": output_tail,
}
)
except subprocess.TimeoutExpired as exc:
failed += 1
_emit_log(log_callback, "ERROR", f"GF3 SARscape timed out for {scene_name}: {exc}")
results.append(
{
"scene_name": scene_name,
"input_path": str(input_path),
"scene_dir": str(scene_dir),
"status": "failed",
"error": f"timeout after {timeout}s",
}
)
except Exception as exc:
failed += 1
_emit_log(log_callback, "ERROR", f"GF3 SARscape exception for {scene_name}: {exc}")
results.append(
{
"scene_name": scene_name,
"input_path": str(input_path),
"scene_dir": str(scene_dir),
"status": "failed",
"error": str(exc),
}
)
_emit_progress(progress_callback, 70, "GF3 SARscape production stage finished.")
return {
"ok": failed == 0,
"found_count": total,
"processed_count": processed,
"skipped_count": skipped,
"failed_count": failed,
"deferred_count": deferred,
"native_root": str(native_root_path),
"missing_roots": discovery.get("missing_roots") or [],
"results": results,
}
def _is_relative_to(path: Path, parent: Path) -> bool:
try:
path.resolve().relative_to(parent.resolve())
return True
except ValueError:
return False
def _entry_size(path: Path) -> int:
try:
if path.is_file():
return int(path.stat().st_size)
if path.is_dir():
total = 0
for current, _dir_names, file_names in os.walk(path):
for file_name in file_names:
file_path = Path(current) / file_name
try:
total += int(file_path.stat().st_size)
except OSError:
continue
return total
except OSError:
return 0
return 0
def _is_final_geo_asset_file(path: Path) -> bool:
name = path.name.lower()
if name in KEEP_FILE_NAMES or name.endswith(".log"):
return True
return (
name.endswith("_geo")
or name.endswith("_geo.hdr")
or name.endswith("_geo.sml")
or name.endswith("_geo.ovr")
or name.endswith("_geo.aux.xml")
or name.endswith("_geo.kml")
or name.endswith("_geo_ql.tif")
or name.endswith("_geo_ql.kml")
)
def _is_known_intermediate_file(path: Path) -> bool:
name = path.name.lower()
if _is_final_geo_asset_file(path):
return False
if any(token in name for token in ("_slc", "_ml", "_filt")):
return True
if name.endswith(INTERMEDIATE_SUFFIXES):
return True
return False
def _standard_manifest_path(scene_manifest: dict[str, Any], storage_root: Path) -> Path:
batch_name = _safe_slug(scene_manifest.get("batch_name") or (scene_manifest.get("metadata") or {}).get("imaging_date"), default="unknown_batch")
scene_name = _safe_slug(scene_manifest.get("scene_name"), default="unknown_scene")
return storage_root / batch_name / scene_name / STANDARD_MANIFEST_NAME
def _standard_manifest_allows_cleanup(scene_manifest: dict[str, Any], storage_root: Path) -> bool:
manifest = _read_json(_standard_manifest_path(scene_manifest, storage_root))
if not manifest:
return False
return str(manifest.get("status") or "").upper() == "DONE"
def _assert_safe_delete(target: Path, scene_dir: Path, native_roots: list[Path]) -> None:
resolved_target = target.resolve()
resolved_scene = scene_dir.resolve()
if resolved_target == resolved_scene:
raise RuntimeError(f"Refusing to delete scene directory itself: {resolved_target}")
if not _is_relative_to(resolved_target, resolved_scene):
raise RuntimeError(f"Refusing to delete outside scene directory: {resolved_target}")
if not any(_is_relative_to(resolved_target, root) for root in native_roots):
raise RuntimeError(f"Refusing to delete outside GF3 native roots: {resolved_target}")
def _cleanup_scene_intermediates(
scene_manifest: dict[str, Any],
*,
native_roots: list[Path],
dry_run: bool,
) -> dict[str, Any]:
scene_dir = Path(str(scene_manifest.get("native_dir") or "")).resolve()
if not scene_dir.is_dir():
return {
"scene_name": scene_manifest.get("scene_name"),
"scene_dir": str(scene_dir),
"status": "skipped",
"reason": "scene directory missing",
"deleted_entries": [],
"bytes_deleted": 0,
}
if not any(_is_relative_to(scene_dir, root) for root in native_roots):
return {
"scene_name": scene_manifest.get("scene_name"),
"scene_dir": str(scene_dir),
"status": "skipped",
"reason": "scene directory is outside configured native roots",
"deleted_entries": [],
"bytes_deleted": 0,
}
candidates: list[Path] = []
for entry in sorted(scene_dir.iterdir(), key=lambda item: item.name.lower()):
if entry.is_dir() and entry.name.lower() in INTERMEDIATE_DIR_NAMES:
candidates.append(entry)
elif entry.is_file() and _is_known_intermediate_file(entry):
candidates.append(entry)
deleted_entries: list[dict[str, Any]] = []
bytes_deleted = 0
errors: list[dict[str, str]] = []
for target in candidates:
try:
_assert_safe_delete(target, scene_dir, native_roots)
size = _entry_size(target)
bytes_deleted += size
deleted_entries.append(
{
"path": str(target),
"type": "directory" if target.is_dir() else "file",
"size": size,
}
)
if not dry_run:
if target.is_dir():
shutil.rmtree(target)
else:
target.unlink()
except Exception as exc:
errors.append({"path": str(target), "error": str(exc)})
status = "cleaned" if deleted_entries and not errors else ("error" if errors else "nothing_to_delete")
cleanup_manifest = {
"schema": "gf3_sarscape_cleanup.v1",
"generated_at": _utc_now(),
"dry_run": dry_run,
"scene_name": scene_manifest.get("scene_name"),
"scene_dir": str(scene_dir),
"status": status,
"bytes_deleted": bytes_deleted,
"deleted_entries": deleted_entries,
"errors": errors,
"retention_policy": {
"keep": "final *_geo native assets, quicklooks, manifests, and logs",
"delete": "extract/temp/work directories and slc/ml/filt intermediate files",
},
}
if not dry_run:
_write_json(scene_dir / CLEANUP_MANIFEST_NAME, cleanup_manifest)
return cleanup_manifest
def cleanup_gf3_sarscape_native_pool(
*,
native_dirs: list[str] | None = None,
storage_root: str | None = None,
require_standardized: bool = True,
dry_run: bool = False,
max_scenes: int | None = None,
log_callback: LogCallback | None = None,
progress_callback: ProgressCallback | None = None,
) -> dict[str, Any]:
"""Delete intermediate SARscape files while keeping final native _geo assets."""
native_dirs = native_dirs if native_dirs is not None else split_env_paths(settings.GF3_SARSCAPE_NATIVE_DIRS)
native_roots, missing_roots = _resolve_existing_dirs(native_dirs)
if not native_roots:
raise ValueError("GF3_SARSCAPE_NATIVE_DIRS has no accessible directories.")
storage = Path(os.path.normpath(storage_root or settings.GF3_STORAGE_DIRS)).resolve() if storage_root or settings.GF3_STORAGE_DIRS else None
inventory = scan_gf3_sarscape_native_roots([str(root) for root in native_roots], write_manifest=not dry_run)
scenes = inventory.get("scenes") or []
max_count = int(max_scenes or 0)
cleaned = 0
skipped = 0
error_count = 0
bytes_deleted = 0
scene_results: list[dict[str, Any]] = []
for idx, scene_manifest in enumerate(scenes):
_emit_progress(
progress_callback,
5 + int((idx / max(len(scenes), 1)) * 90),
f"GF3 native cleanup checking {idx + 1}/{len(scenes)}: {scene_manifest.get('scene_name')}",
)
scene_status = str(scene_manifest.get("status") or "")
if scene_status != "NATIVE_READY":
skipped += 1
scene_results.append(
{
"scene_name": scene_manifest.get("scene_name"),
"status": "skipped",
"reason": f"native status is {scene_status or 'UNKNOWN'}",
}
)
continue
if require_standardized:
if storage is None:
skipped += 1
scene_results.append(
{
"scene_name": scene_manifest.get("scene_name"),
"status": "skipped",
"reason": "GF3_STORAGE_DIRS is not configured",
}
)
continue
if not _standard_manifest_allows_cleanup(scene_manifest, storage):
skipped += 1
scene_results.append(
{
"scene_name": scene_manifest.get("scene_name"),
"status": "skipped",
"reason": "standard GeoTIFF manifest is not DONE",
}
)
continue
if max_count > 0 and cleaned >= max_count:
skipped += 1
scene_results.append(
{
"scene_name": scene_manifest.get("scene_name"),
"status": "skipped",
"reason": "max_scenes limit reached",
}
)
continue
result = _cleanup_scene_intermediates(scene_manifest, native_roots=native_roots, dry_run=dry_run)
scene_results.append(result)
bytes_deleted += int(result.get("bytes_deleted") or 0)
if result.get("status") == "error":
error_count += 1
if result.get("status") in {"cleaned", "nothing_to_delete"}:
cleaned += 1
_emit_log(
log_callback,
"INFO",
f"GF3 cleanup {result.get('status')}: {result.get('scene_name')} bytes={result.get('bytes_deleted')}",
)
_emit_progress(progress_callback, 100, "GF3 native cleanup finished.")
return {
"ok": error_count == 0,
"dry_run": dry_run,
"native_roots": [str(root) for root in native_roots],
"missing_roots": missing_roots,
"scene_count": len(scenes),
"cleaned_scene_count": cleaned,
"skipped_scene_count": skipped,
"error_scene_count": error_count,
"bytes_deleted": bytes_deleted,
"scenes": scene_results,
}
@@ -0,0 +1,927 @@
"""Convert GF3 SARscape native geocoded outputs to platform GeoTIFFs."""
from __future__ import annotations
import asyncio
import hashlib
import json
import math
import os
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from geoalchemy2.shape import from_shape
from shapely.geometry import Polygon
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..models import ManagedRootORM, RadarDataORM, SARSceneGeoORM, SourceProductAssetORM
from .data_service import extract_geotiff_bounds
from .gf3_native_inventory_service import (
NATIVE_MANIFEST_NAME,
POLARIZATION_PRIORITY,
scan_gf3_sarscape_native_roots,
)
from .image_service import image_service
from .sar_analysis_ready_service import register_analysis_ready_tif
STANDARD_MANIFEST_NAME = "gf3_standard_manifest.json"
STANDARD_MANIFEST_SCHEMA = "gf3_standard_geotiff.v1"
CONVERTER_NAME = "gf3_sarscape_geo_to_tif"
CONVERTER_VERSION = "v1"
SOURCE_ASSET_FORMAT = "GF3_SARSCAPE_L2"
def _utc_now() -> str:
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _path_text(value: Any) -> str:
text = str(value or "").strip()
return os.path.normpath(text) if text else ""
def _db_now() -> datetime:
return datetime.now(timezone.utc).replace(tzinfo=None, microsecond=0)
def _path_kind(path: str) -> str:
text = str(path or "").strip()
if text.startswith("\\\\"):
return "unc"
if len(text) >= 3 and text[1:3] in {":\\", ":/"} and text[0].isalpha():
return "windows"
if text.startswith("/mnt/"):
return "wsl_mount"
if text.startswith("/"):
return "posix"
return "relative"
def _source_asset_uid(path: str) -> str:
normalized = os.path.normpath(str(path or "").strip())
digest = hashlib.sha1(normalized.lower().encode("utf-8", errors="ignore")).hexdigest()
return f"source:{digest[:32]}"
def _safe_slug(value: Any, *, default: str = "unknown") -> str:
text = str(value or "").strip()
if not text:
text = default
safe = "".join(ch if ch.isalnum() or ch in "._-" else "_" for ch in text).strip("._-")
return safe or default
def _write_json(path: Path, payload: dict[str, Any]) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp_path = path.with_name(f".{path.name}.tmp")
with tmp_path.open("w", encoding="utf-8") as stream:
json.dump(_json_safe(payload), stream, ensure_ascii=False, indent=2, default=str, allow_nan=False)
os.replace(tmp_path, path)
def _json_safe(value: Any) -> Any:
if isinstance(value, float):
return value if math.isfinite(value) else None
if isinstance(value, dict):
return {key: _json_safe(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(item) for item in value]
return value
def _finite_float(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) else None
def _read_json(path: Path) -> dict[str, Any] | None:
try:
with path.open("r", encoding="utf-8") as stream:
data = json.load(stream)
return data if isinstance(data, dict) else None
except (OSError, json.JSONDecodeError):
return None
def _file_fingerprint(path: Path) -> dict[str, Any] | None:
try:
stat = path.stat()
except OSError:
return None
return {
"path": str(path),
"size": int(stat.st_size),
"mtime": float(stat.st_mtime),
"mtime_ns": int(stat.st_mtime_ns),
}
def _tree_stats(path: Path) -> dict[str, Any]:
if path.is_file():
info = _file_fingerprint(path)
return {
"size_bytes": info.get("size") if info else None,
"mtime_epoch": info.get("mtime") if info else None,
}
total = 0
newest: float | None = None
try:
iterator = path.rglob("*")
for item in iterator:
try:
if not item.is_file():
continue
stat = item.stat()
except OSError:
continue
total += int(stat.st_size)
mtime = float(stat.st_mtime)
newest = mtime if newest is None else max(newest, mtime)
except OSError:
return {"size_bytes": None, "mtime_epoch": None}
return {"size_bytes": total, "mtime_epoch": newest}
async def _find_managed_root_for_path(db: AsyncSession, path: str) -> ManagedRootORM | None:
target = os.path.normcase(os.path.normpath(str(path or "")))
if not target:
return None
result = await db.execute(
select(ManagedRootORM)
.where(ManagedRootORM.enabled == True) # noqa: E712
.order_by(func.length(ManagedRootORM.path).desc())
)
for root in result.scalars().all():
root_path = os.path.normcase(os.path.normpath(str(root.path or "")))
if target == root_path or target.startswith(root_path + os.sep):
return root
return None
def _batch_name(scene_manifest: dict[str, Any]) -> str:
raw = scene_manifest.get("batch_name") or (scene_manifest.get("metadata") or {}).get("imaging_date")
return _safe_slug(raw, default="unknown_batch")
def _standard_scene_dir(scene_manifest: dict[str, Any], storage_root: Path) -> Path:
return storage_root / _batch_name(scene_manifest) / _safe_slug(scene_manifest.get("scene_name"))
def _target_tif_path(scene_manifest: dict[str, Any], asset: dict[str, Any], storage_root: Path) -> Path:
pol = _safe_slug(asset.get("polarization"), default="UNKNOWN").upper()
return _standard_scene_dir(scene_manifest, storage_root) / f"{pol}_L2.tif"
def _preview_path(scene_manifest: dict[str, Any], asset: dict[str, Any], storage_root: Path) -> Path:
pol = _safe_slug(asset.get("polarization"), default="UNKNOWN").upper()
return _standard_scene_dir(scene_manifest, storage_root) / f"preview_{pol}.png"
def _quality_path(scene_manifest: dict[str, Any], asset: dict[str, Any], storage_root: Path) -> Path:
pol = _safe_slug(asset.get("polarization"), default="UNKNOWN").upper()
return _standard_scene_dir(scene_manifest, storage_root) / f"quality_{pol}.json"
def _source_changed(asset: dict[str, Any], target_tif: Path, existing_asset: dict[str, Any] | None) -> bool:
source_path = Path(_path_text(asset.get("path")))
source_fp = _file_fingerprint(source_path)
if not target_tif.is_file() or target_tif.stat().st_size <= 0:
return True
if not existing_asset:
return True
if str(existing_asset.get("source_native") or "") != str(source_path):
return True
if (existing_asset.get("source_fingerprint") or {}) != source_fp:
return True
if str(existing_asset.get("converter_version") or "") != CONVERTER_VERSION:
return True
return False
def _existing_manifest_asset(standard_manifest: dict[str, Any] | None, polarization: str) -> dict[str, Any] | None:
if not standard_manifest:
return None
target_pol = str(polarization or "").upper()
for item in standard_manifest.get("assets") or []:
if str(item.get("polarization") or "").upper() == target_pol:
return item
return None
def _convert_native_asset_to_tif(asset: dict[str, Any], target_tif: Path) -> dict[str, Any]:
source_path = Path(_path_text(asset.get("path")))
if not source_path.is_file():
raise FileNotFoundError(f"GF3 native data file does not exist: {source_path}")
hdr_path = Path(_path_text(asset.get("hdr")))
if not hdr_path.is_file():
raise FileNotFoundError(f"GF3 native ENVI header does not exist: {hdr_path}")
target_tif.parent.mkdir(parents=True, exist_ok=True)
tmp_tif = target_tif.with_name(f".{target_tif.name}.tmp.tif")
if tmp_tif.exists():
tmp_tif.unlink()
try:
from osgeo import gdal
src_ds = gdal.Open(str(source_path), gdal.GA_ReadOnly)
if src_ds is None:
raise RuntimeError(f"GDAL cannot open GF3 native dataset: {source_path}")
creation_options = ["TILED=YES", "COMPRESS=DEFLATE", "BIGTIFF=IF_SAFER"]
if bool(settings.SAR_ANALYSIS_OUTPUT_COG):
creation_options.append("COPY_SRC_OVERVIEWS=YES")
translated = gdal.Translate(
str(tmp_tif),
src_ds,
format="GTiff",
creationOptions=creation_options,
)
src_ds = None
if translated is None:
raise RuntimeError(f"GDAL Translate failed for GF3 native dataset: {source_path}")
translated.FlushCache()
translated = None
except ImportError:
import rasterio
with rasterio.open(source_path) as src:
profile = src.profile.copy()
profile.update(
driver="GTiff",
tiled=True,
compress="deflate",
BIGTIFF="IF_SAFER",
)
with rasterio.open(tmp_tif, "w", **profile) as dst:
for band_idx in range(1, src.count + 1):
for _block_index, window in src.block_windows(band_idx):
dst.write(src.read(band_idx, window=window), band_idx, window=window)
dst.update_tags(**src.tags())
for band_idx in range(1, src.count + 1):
dst.update_tags(band_idx, **src.tags(band_idx))
os.replace(tmp_tif, target_tif)
return {
"path": str(target_tif),
"source_native": str(source_path),
"source_fingerprint": _file_fingerprint(source_path),
"converter_name": CONVERTER_NAME,
"converter_version": CONVERTER_VERSION,
}
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
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(src.transform)[:6],
"nodata": _finite_float(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 _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(float("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)
normalized = np.where(np.isfinite(normalized), normalized, 0)
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)
def _build_preview_from_quicklook(source: Path | None, target: Path) -> str | None:
if source is None or not source.is_file():
return None
try:
from PIL import Image
except Exception:
return None
target.parent.mkdir(parents=True, exist_ok=True)
try:
with Image.open(source) as img:
preview = img.copy()
resampling = getattr(getattr(Image, "Resampling", Image), "LANCZOS")
preview.thumbnail((1600, 1600), resampling)
if preview.mode in {"1", "I", "I;16", "F"}:
preview = preview.convert("L")
elif preview.mode not in {"L", "LA", "RGB", "RGBA"}:
preview = preview.convert("RGB")
preview = image_service.make_edge_dark_transparent(preview)
preview.save(target, "PNG")
return str(target)
except Exception:
return None
def _asset_quicklook_path(asset: dict[str, Any]) -> Path | None:
text = _path_text(asset.get("quicklook"))
if not text:
return None
return Path(text)
def _points_look_like_lonlat(points: list[tuple[float, float]]) -> bool:
if not points:
return False
for lon, lat in points:
if not (math.isfinite(float(lon)) and math.isfinite(float(lat))):
return False
if not (-180.0 <= float(lon) <= 180.0 and -90.0 <= float(lat) <= 90.0):
return False
return True
def _crs_is_geographic_lonlat(crs: Any) -> bool:
if not crs:
return False
try:
if crs.to_epsg() == 4326:
return True
except Exception:
pass
try:
if bool(crs.is_geographic):
return True
except Exception:
pass
try:
wkt = str(crs.to_wkt() or "").upper()
if "GEOGCS" in wkt and ("WGS 84" in wkt or "WORLD GEODETIC" in wkt):
return True
except Exception:
pass
return False
def _polygon_from_tif(path: Path) -> list[tuple[float, float]] | None:
polygon = extract_geotiff_bounds(str(path))
if polygon and len(polygon) >= 4:
return polygon
try:
import rasterio
from rasterio.warp import transform
with rasterio.open(path) as src:
if not src.crs:
return None
corners_xy = [
src.transform * (0, 0),
src.transform * (src.width, 0),
src.transform * (src.width, src.height),
src.transform * (0, src.height),
]
xs = [point[0] for point in corners_xy]
ys = [point[1] for point in corners_xy]
raw_points = [(float(x), float(y)) for x, y in zip(xs, ys)]
if _crs_is_geographic_lonlat(src.crs):
points = raw_points
else:
try:
lons, lats = transform(src.crs, "EPSG:4326", xs, ys)
points = [(float(lon), float(lat)) for lon, lat in zip(lons, lats)]
except Exception:
if not _points_look_like_lonlat(raw_points):
raise
points = raw_points
if not _points_look_like_lonlat(points):
return None
points.append(points[0])
return points
except Exception:
return None
return None
def _scene_center_from_polygon(polygon: list[tuple[float, float]] | None) -> tuple[float | None, float | None]:
if not polygon:
return None, None
try:
shp = Polygon(polygon)
if not shp.is_valid:
shp = shp.buffer(0)
if shp.is_valid and not shp.is_empty:
return float(shp.centroid.x), float(shp.centroid.y)
except Exception:
return None, None
return None, None
def _bounds_from_polygon(polygon: list[tuple[float, float]] | None) -> tuple[float | None, float | None, float | None, float | None]:
if not polygon:
return None, None, None, None
lons = [float(point[0]) for point in polygon]
lats = [float(point[1]) for point in polygon]
return min(lons), min(lats), max(lons), max(lats)
def _geom_from_polygon(polygon: list[tuple[float, float]] | None) -> Any | None:
if not polygon:
return None
try:
shp = Polygon(polygon)
if not shp.is_valid:
shp = shp.buffer(0)
if shp.is_valid and not shp.is_empty:
return from_shape(shp, srid=4326)
except Exception:
return None
return None
def _select_default_asset(assets: list[dict[str, Any]]) -> dict[str, Any] | None:
by_pol = {str(asset.get("polarization") or "").upper(): asset for asset in assets}
for pol in POLARIZATION_PRIORITY:
if pol in by_pol:
return by_pol[pol]
return assets[0] if assets else None
def _metadata_for_radar(scene_manifest: dict[str, Any], standard_manifest: dict[str, Any]) -> dict[str, Any]:
metadata = dict(scene_manifest.get("metadata") or {})
metadata.update(
{
"native_dir": scene_manifest.get("native_dir"),
"native_manifest": scene_manifest.get("manifest_path"),
"standard_manifest": standard_manifest.get("manifest_path"),
"standard_dir": standard_manifest.get("standard_dir"),
"standard_assets": standard_manifest.get("assets") or [],
"analysis_engine": "gf3_sarscape",
}
)
return metadata
async def _upsert_source_product_asset(
db: AsyncSession,
scene_manifest: dict[str, Any],
standard_manifest: dict[str, Any],
) -> int | None:
standard_dir_text = _path_text(standard_manifest.get("standard_dir"))
if not standard_dir_text:
return None
standard_dir = Path(standard_dir_text)
metadata = scene_manifest.get("metadata") or {}
scene_name = scene_manifest.get("scene_name") or standard_dir.name
now = _db_now()
root = await _find_managed_root_for_path(db, standard_dir_text)
stats = await asyncio.to_thread(_tree_stats, standard_dir)
asset_metadata = _json_safe(
{
"source": "GF3 SARscape standardized L2",
"native_dir": scene_manifest.get("native_dir"),
"native_manifest": scene_manifest.get("manifest_path"),
"standard_manifest": standard_manifest.get("manifest_path"),
"standard_dir": standard_dir_text,
"standard_status": standard_manifest.get("status"),
"standard_assets": standard_manifest.get("assets") or [],
"summary": standard_manifest.get("summary") or {},
"errors": standard_manifest.get("errors") or [],
"analysis_engine": "gf3_sarscape",
}
)
data = {
"asset_uid": _source_asset_uid(standard_dir_text),
"logical_product_uid": scene_name,
"satellite_family": "GF3",
"satellite": "GF3",
"source_format": SOURCE_ASSET_FORMAT,
"product_type": metadata.get("product_type") or "SARSCAPE_L2",
"product_level": "L2",
"imaging_mode": metadata.get("imaging_mode"),
"polarization": metadata.get("polarization"),
"absolute_orbit": metadata.get("absolute_orbit") or metadata.get("orbit_circle"),
"relative_orbit": metadata.get("relative_orbit"),
"orbit_direction": metadata.get("orbit_direction"),
"acquisition_start_time_utc": None,
"acquisition_stop_time_utc": None,
"imaging_date": metadata.get("imaging_date"),
"root_ref_id": root.id if root else None,
"root_path": root.path if root else str(standard_dir.parent),
"file_path": standard_dir_text,
"archive_path": scene_manifest.get("native_dir"),
"path_kind": _path_kind(standard_dir_text),
"file_name": standard_dir.name,
"file_stem": standard_dir.name,
"file_ext": "",
"size_bytes": stats.get("size_bytes"),
"mtime_epoch": stats.get("mtime_epoch"),
"checksum_status": "NOT_COMPUTED",
"parser_name": "gf3_sarscape_standard_manifest",
"parser_version": CONVERTER_VERSION,
"parse_status": "OK" if standard_manifest.get("status") == "DONE" else str(standard_manifest.get("status") or "PARTIAL"),
"parse_error": "; ".join(str(item.get("error") or item) for item in (standard_manifest.get("errors") or [])) or None,
"parsed_at": now,
"metadata_json": asset_metadata,
"is_active": True,
"missing_since": None,
"updated_at": now,
}
result = await db.execute(
select(SourceProductAssetORM).where(
or_(
SourceProductAssetORM.asset_uid == data["asset_uid"],
SourceProductAssetORM.file_path == standard_dir_text,
)
)
)
asset = result.scalars().first()
if asset is None:
asset = SourceProductAssetORM(**data)
db.add(asset)
else:
for key, value in data.items():
setattr(asset, key, value)
await db.flush()
return int(asset.id) if asset.id is not None else None
async def _upsert_radar_data(
db: AsyncSession,
scene_manifest: dict[str, Any],
standard_manifest: dict[str, Any],
source_product_ref_id: int | None = None,
) -> int | None:
assets = standard_manifest.get("assets") or []
default_asset = _select_default_asset(assets)
if not default_asset:
return None
polygon = _polygon_from_tif(Path(_path_text(default_asset.get("path"))))
min_lon, min_lat, max_lon, max_lat = _bounds_from_polygon(polygon)
center_lon, center_lat = _scene_center_from_polygon(polygon)
geom = _geom_from_polygon(polygon)
metadata = scene_manifest.get("metadata") or {}
radar_metadata = _metadata_for_radar(scene_manifest, standard_manifest)
scene_name = scene_manifest.get("scene_name") or Path(str(scene_manifest.get("native_dir") or "")).name
unique_id = f"gf3_sarscape:{scene_name}"
file_path = str(standard_manifest.get("standard_dir") or "")
data_to_upsert = {
"unique_id": unique_id,
"satellite": "GF3",
"satellite_family": "GF3",
"imaging_date": metadata.get("imaging_date"),
"imaging_mode": metadata.get("imaging_mode"),
"polarization": ",".join(
pol
for pol in POLARIZATION_PRIORITY
if any(str(asset.get("polarization") or "").upper() == pol for asset in assets)
)
or metadata.get("polarization"),
"scene_center_lon": metadata.get("scene_center_lon") if metadata.get("scene_center_lon") is not None else center_lon,
"scene_center_lat": metadata.get("scene_center_lat") if metadata.get("scene_center_lat") is not None else center_lat,
"product_level": "L2",
"product_unique_id": metadata.get("product_unique_id"),
"source_format": SOURCE_ASSET_FORMAT,
"source_product_ref_id": source_product_ref_id,
"image_data_format": "GEOTIFF",
"geocoded_flag": True,
"metadata_json": radar_metadata,
"file_path": file_path,
"has_orbit_data": False,
"orbit_file_path": None,
"is_envi_processed": True,
"coverage_polygon": polygon,
"geom": geom,
"min_lon": min_lon,
"min_lat": min_lat,
"max_lon": max_lon,
"max_lat": max_lat,
}
result = await db.execute(
select(RadarDataORM).where(
or_(
RadarDataORM.unique_id == unique_id,
RadarDataORM.file_path == file_path,
)
)
)
radar = result.scalars().first()
if radar is None:
radar = RadarDataORM(**data_to_upsert)
db.add(radar)
else:
for key, value in data_to_upsert.items():
setattr(radar, key, value)
await db.flush()
return int(radar.id) if radar.id is not None else None
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(
db: AsyncSession,
radar_id: int,
scene_manifest: dict[str, Any],
standard_manifest: dict[str, Any],
) -> dict[str, Any] | None:
radar = await db.get(RadarDataORM, radar_id)
if not radar:
return None
assets = standard_manifest.get("assets") or []
default_asset = _select_default_asset(assets)
if not default_asset:
return None
scene = await _get_or_create_scene(db, radar_id)
return await register_analysis_ready_tif(
db=db,
scene=scene,
radar=radar,
source_tif_path=str(default_asset.get("path") or ""),
engine="gf3_sarscape",
profile=CONVERTER_NAME,
backscatter_unit="unknown",
polarization=str(default_asset.get("polarization") or "").upper() or None,
preview_source_path=str(default_asset.get("preview") or "") or None,
metadata={
"source": "GF3 SARscape native _geo",
"native_dir": scene_manifest.get("native_dir"),
"native_manifest": scene_manifest.get("manifest_path"),
"standard_manifest": standard_manifest.get("manifest_path"),
"available_polarization": [asset.get("polarization") for asset in assets],
"standard_assets": assets,
},
)
def standardize_scene_manifest(
scene_manifest: dict[str, Any],
*,
storage_root: str | Path | None = None,
force: bool = False,
) -> dict[str, Any]:
"""Convert one native scene manifest to GeoTIFF assets."""
root = Path(storage_root or settings.GF3_STORAGE_DIRS).resolve()
out_dir = _standard_scene_dir(scene_manifest, root)
manifest_path = out_dir / STANDARD_MANIFEST_NAME
existing_manifest = _read_json(manifest_path)
converted = 0
skipped = 0
failed = 0
output_assets: list[dict[str, Any]] = []
errors: list[dict[str, str]] = []
for asset in scene_manifest.get("assets") or []:
if not asset.get("complete"):
continue
pol = str(asset.get("polarization") or "UNKNOWN").upper()
target_tif = _target_tif_path(scene_manifest, asset, root)
existing_asset = _existing_manifest_asset(existing_manifest, pol)
try:
if force or _source_changed(asset, target_tif, existing_asset):
convert_info = _convert_native_asset_to_tif(asset, target_tif)
converted += 1
status = "converted"
else:
convert_info = {
"path": str(target_tif),
"source_native": str(Path(_path_text(asset.get("path")))),
"source_fingerprint": _file_fingerprint(Path(_path_text(asset.get("path")))),
"converter_name": CONVERTER_NAME,
"converter_version": CONVERTER_VERSION,
}
skipped += 1
status = "skipped"
quality = _raster_quality(target_tif)
quality_file = _quality_path(scene_manifest, asset, root)
_write_json(quality_file, quality)
preview_target = _preview_path(scene_manifest, asset, root)
quicklook_path = _asset_quicklook_path(asset)
preview = _build_preview_from_quicklook(quicklook_path, preview_target)
preview_source = str(quicklook_path) if preview and quicklook_path else str(target_tif)
if not preview:
preview = _build_preview_png(target_tif, preview_target)
output_assets.append(
{
"polarization": pol,
"role": "analysis_tif",
"path": str(target_tif),
"source_native": convert_info["source_native"],
"source_fingerprint": convert_info["source_fingerprint"],
"converter_name": CONVERTER_NAME,
"converter_version": CONVERTER_VERSION,
"quality": str(quality_file),
"preview": preview,
"preview_source": preview_source,
"status": status,
}
)
except Exception as exc:
failed += 1
errors.append({"polarization": pol, "source_native": str(asset.get("path") or ""), "error": str(exc)})
status = "DONE" if output_assets and failed == 0 else ("PARTIAL" if output_assets else "FAILED")
standard_manifest = {
"schema": STANDARD_MANIFEST_SCHEMA,
"generated_at": _utc_now(),
"scene_name": scene_manifest.get("scene_name"),
"batch_name": scene_manifest.get("batch_name"),
"native_manifest": scene_manifest.get("manifest_path") or str(Path(scene_manifest.get("native_dir") or "") / NATIVE_MANIFEST_NAME),
"native_dir": scene_manifest.get("native_dir"),
"standard_dir": str(out_dir),
"manifest_path": str(manifest_path),
"status": status,
"converter": {"name": CONVERTER_NAME, "version": CONVERTER_VERSION},
"assets": output_assets,
"summary": {
"converted": converted,
"skipped": skipped,
"failed": failed,
},
"errors": errors,
}
_write_json(manifest_path, standard_manifest)
return standard_manifest
async def standardize_gf3_sarscape_native_roots(
db: AsyncSession,
*,
native_dirs: list[str] | None = None,
storage_root: str | None = None,
force: bool = False,
register: bool = True,
progress_callback: Any | None = None,
) -> dict[str, Any]:
"""Scan native roots, convert complete assets, and register standard scenes."""
inventory = await asyncio.to_thread(
scan_gf3_sarscape_native_roots,
native_dirs,
write_manifest=True,
)
scenes = inventory.get("scenes") or []
ready_scenes = [scene for scene in scenes if scene.get("status") in {"NATIVE_READY", "PARTIAL"}]
converted_scenes = 0
partial_scenes = 0
failed_scenes = 0
skipped_assets = 0
converted_assets = 0
failed_assets = 0
registered = 0
analysis_ready = 0
scene_results: list[dict[str, Any]] = []
total = len(ready_scenes)
for idx, scene_manifest in enumerate(ready_scenes):
if progress_callback:
pct = 10 + int((idx / max(total, 1)) * 80)
progress_callback(pct, f"标准化 GF3 SARscape 原生结果 {idx + 1}/{total}: {scene_manifest.get('scene_name')}")
standard_manifest = await asyncio.to_thread(
standardize_scene_manifest,
scene_manifest,
storage_root=storage_root,
force=force,
)
summary = standard_manifest.get("summary") or {}
converted_assets += int(summary.get("converted") or 0)
skipped_assets += int(summary.get("skipped") or 0)
failed_assets += int(summary.get("failed") or 0)
status = standard_manifest.get("status")
if status == "DONE":
converted_scenes += 1
elif status == "PARTIAL":
partial_scenes += 1
else:
failed_scenes += 1
radar_id = None
source_asset_id = None
analysis_manifest_path = None
if register and status in {"DONE", "PARTIAL"}:
source_asset_id = await _upsert_source_product_asset(db, scene_manifest, standard_manifest)
radar_id = await _upsert_radar_data(
db,
scene_manifest,
standard_manifest,
source_product_ref_id=source_asset_id,
)
if radar_id:
registered += 1
analysis_manifest = await _register_analysis_ready(db, radar_id, scene_manifest, standard_manifest)
if analysis_manifest:
analysis_ready += 1
analysis_manifest_path = analysis_manifest.get("analysis_dir")
await db.commit()
scene_results.append(
{
"scene_name": scene_manifest.get("scene_name"),
"native_status": scene_manifest.get("status"),
"standard_status": status,
"standard_manifest": standard_manifest.get("manifest_path"),
"source_asset_id": source_asset_id,
"radar_id": radar_id,
"analysis_manifest_path": analysis_manifest_path,
"summary": summary,
"errors": standard_manifest.get("errors") or [],
}
)
return {
"ok": failed_scenes == 0 and failed_assets == 0,
"inventory": {
key: value
for key, value in inventory.items()
if key != "scenes"
},
"scene_count": len(scenes),
"ready_scene_count": len(ready_scenes),
"converted_scenes": converted_scenes,
"partial_scenes": partial_scenes,
"failed_scenes": failed_scenes,
"converted_assets": converted_assets,
"skipped_assets": skipped_assets,
"failed_assets": failed_assets,
"registered": registered,
"analysis_ready": analysis_ready,
"scenes": scene_results,
}
+53
View File
@@ -879,6 +879,36 @@ def _probe_directory_status(path: str) -> Dict[str, Any]:
return payload
def _probe_file_status(path: str) -> Dict[str, Any]:
normalized = str(path or "").strip()
payload = {
"path": normalized,
"exists": False,
"accessible": False,
"error": None,
}
if not normalized:
payload["error"] = "empty path"
return payload
try:
if os.path.isfile(normalized):
payload["exists"] = True
try:
with open(normalized, "rb") as stream:
stream.read(1)
payload["accessible"] = True
except Exception as exc:
payload["error"] = str(exc)
return payload
os.stat(normalized)
payload["error"] = "path exists but is not a file"
except Exception as exc:
payload["error"] = str(exc)
return payload
async def _check_source_roots() -> Dict[str, Any]:
items = []
@@ -908,11 +938,34 @@ async def _check_source_roots() -> Dict[str, Any]:
status["role"] = "gf3_l1a_source"
items.append(status)
for path in split_env_paths(settings.GF3_SARSCAPE_NATIVE_DIRS):
status = _probe_directory_status(path)
status["role"] = "gf3_sarscape_native"
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)
wrapper_exe = str(settings.GF3_SARSCAPE_WRAPPER_EXE or "").strip()
if wrapper_exe:
status = _probe_file_status(wrapper_exe)
status["role"] = "gf3_sarscape_wrapper"
items.append(status)
idlrt_path = str(settings.GF3_SARSCAPE_IDLRT_PATH or "").strip()
if idlrt_path:
status = _probe_file_status(idlrt_path)
status["role"] = "gf3_sarscape_idlrt"
items.append(status)
dem_path = str(settings.GF3_SARSCAPE_DEM_PATH or settings.GF3_GEO_DEM_PATH or "").strip()
if dem_path:
status = _probe_file_status(dem_path)
status["role"] = "gf3_sarscape_dem"
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
+61 -11
View File
@@ -15,6 +15,7 @@
import os
import time
import json
from collections import deque
from typing import Tuple, Optional, Dict, Any, List
from PIL import Image
import rasterio
@@ -334,6 +335,55 @@ class ImageService:
"""
os.makedirs(os.path.dirname(output_path), exist_ok=True)
image.save(output_path, format='WEBP', quality=quality)
@staticmethod
def make_edge_dark_transparent(
image: Image.Image,
*,
threshold: int = 6,
) -> Image.Image:
"""Make edge-connected near-black preview background transparent."""
rgba = image.convert("RGBA")
arr = np.array(rgba, dtype=np.uint8, copy=True)
if arr.ndim != 3 or arr.shape[2] < 4:
return rgba
alpha = arr[:, :, 3]
dark = (alpha > 0) & (arr[:, :, :3].max(axis=2) <= int(threshold))
if not dark.any():
return rgba
h, w = dark.shape
edge = np.zeros_like(dark, dtype=bool)
edge[0, :] = dark[0, :]
edge[h - 1, :] = dark[h - 1, :]
edge[:, 0] |= dark[:, 0]
edge[:, w - 1] |= dark[:, w - 1]
if not edge.any():
return rgba
visited = np.zeros_like(dark, dtype=bool)
ys, xs = np.where(edge)
queue = deque(zip(ys.tolist(), xs.tolist()))
visited[ys, xs] = True
while queue:
y, x = queue.popleft()
if y > 0 and dark[y - 1, x] and not visited[y - 1, x]:
visited[y - 1, x] = True
queue.append((y - 1, x))
if y + 1 < h and dark[y + 1, x] and not visited[y + 1, x]:
visited[y + 1, x] = True
queue.append((y + 1, x))
if x > 0 and dark[y, x - 1] and not visited[y, x - 1]:
visited[y, x - 1] = True
queue.append((y, x - 1))
if x + 1 < w and dark[y, x + 1] and not visited[y, x + 1]:
visited[y, x + 1] = True
queue.append((y, x + 1))
arr[:, :, 3][visited] = 0
return Image.fromarray(arr, "RGBA")
@staticmethod
def create_cached_image(
@@ -497,12 +547,12 @@ class ImageService:
@staticmethod
def _warp_preview_to_geo_bbox(
source_rgb: np.ndarray,
source_rgba: np.ndarray,
inverse_h: np.ndarray,
bbox: Tuple[float, float, float, float],
out_size: Tuple[int, int],
) -> np.ndarray:
src_h, src_w = source_rgb.shape[:2]
src_h, src_w = source_rgba.shape[:2]
out_w, out_h = out_size
min_lon, min_lat, max_lon, max_lat = bbox
lon_span = max_lon - min_lon
@@ -557,7 +607,7 @@ class ImageService:
du = (u_valid - x0).astype(np.float32)
dv = (v_valid - y0).astype(np.float32)
src_float = source_rgb.astype(np.float32, copy=False)
src_float = source_rgba.astype(np.float32, copy=False)
s00 = src_float[y0, x0]
s10 = src_float[y0, x1]
s01 = src_float[y1, x0]
@@ -568,15 +618,14 @@ class ImageService:
+ s01 * (1 - du)[:, None] * dv[:, None]
+ s11 * du[:, None] * dv[:, None]
)
rgb = np.clip(samples, 0, 255).astype(np.uint8)
rgba = np.clip(samples, 0, 255).astype(np.uint8)
else:
nearest_x = np.clip(np.round(u_valid).astype(np.int32), 0, src_w - 1)
nearest_y = np.clip(np.round(v_valid).astype(np.int32), 0, src_h - 1)
rgb = source_rgb[nearest_y, nearest_x]
rgba = source_rgba[nearest_y, nearest_x]
flat = output.reshape(-1, 4)
flat[valid_idx, :3] = rgb
flat[valid_idx, 3] = 255
flat[valid_idx] = rgba
return output
@staticmethod
@@ -608,9 +657,10 @@ class ImageService:
return False, "invalid_bbox"
with Image.open(source_image_path) as image:
source_rgb = np.asarray(image.convert("RGB"), dtype=np.uint8)
source = ImageService.make_edge_dark_transparent(image)
source_rgba = np.asarray(source, dtype=np.uint8)
src_h, src_w = source_rgb.shape[:2]
src_h, src_w = source_rgba.shape[:2]
if src_h < 1 or src_w < 1:
return False, "invalid_source_image_size"
@@ -633,7 +683,7 @@ class ImageService:
return False, "homography_invert_failed"
warped_rgba = ImageService._warp_preview_to_geo_bbox(
source_rgb=source_rgb,
source_rgba=source_rgba,
inverse_h=inverse_h,
bbox=bbox,
out_size=out_size,
@@ -672,7 +722,7 @@ class ImageService:
)
with Image.open(source_image_path) as image:
image = image.convert("RGB")
image = ImageService.make_edge_dark_transparent(image)
image.thumbnail(max_size, Image.Resampling.LANCZOS)
ImageService.save_image_as_webp(image, cache_path, quality=82)
return True
+334 -1
View File
@@ -86,6 +86,9 @@ 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_GF3_SARSCAPE_PRODUCE = "GF3_SARSCAPE_PRODUCE"
JOB_TYPE_GF3_SARSCAPE_SYNC = "GF3_SARSCAPE_SYNC"
JOB_TYPE_GF3_SARSCAPE_CLEAN = "GF3_SARSCAPE_CLEAN"
JOB_TYPE_ISCE2_RUN = "ISCE2_RUN"
JOB_TYPE_PYINT_RUN = "PYINT_RUN"
JOB_TYPE_PUBLISH_DINSAR_PRODUCTS = "PUBLISH_DINSAR_PRODUCTS"
@@ -3455,7 +3458,8 @@ async def _handle_water_detect(job: SystemJobORM) -> None:
raise ValueError("水体检测缺少输入路径 input_path")
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)
output_root = settings.WATER_RESULTS_DIR or os.path.join(settings.BACKEND_DIR, "water_results")
output_dir = os.path.join(output_root, output_name)
os.makedirs(output_dir, exist_ok=True)
await task_service.update_task(job.task_id, progress=10, message="启动水体检测算法...")
@@ -3496,6 +3500,7 @@ async def _handle_water_detect(job: SystemJobORM) -> None:
det.threshold_value = result.get("threshold_value")
det.metadata_json = {
"legacy_otsu_threshold_db": result.get("otsu_threshold_db"),
"value_transform": result.get("value_transform"),
"job_id": job.job_id,
}
else:
@@ -3518,6 +3523,7 @@ async def _handle_water_detect(job: SystemJobORM) -> None:
mirror.task_id = job.task_id
mirror.metadata_json = {
"legacy_otsu_threshold_db": result.get("otsu_threshold_db"),
"value_transform": result.get("value_transform"),
"legacy_detection_id": int(record_id),
"job_id": job.job_id,
}
@@ -3781,6 +3787,330 @@ async def _handle_gf3_batch_process(job: SystemJobORM) -> None:
)
async def _handle_gf3_sarscape_sync(job: SystemJobORM) -> None:
"""Scan SARscape native GF3 _geo outputs, convert them to GeoTIFF, and register them."""
from .gf3_standardize_service import standardize_gf3_sarscape_native_roots
if not job.task_id:
raise ValueError("GF3_SARSCAPE_SYNC requires task_id for progress tracking.")
payload = job.payload or {}
native_dirs = payload.get("native_dirs") or []
storage_root = payload.get("storage_root") or settings.GF3_STORAGE_DIRS
if not native_dirs:
raise ValueError("GF3_SARSCAPE_SYNC: native_dirs is empty")
if not storage_root:
raise ValueError("GF3_SARSCAPE_SYNC: storage_root is empty")
await task_service.start_task(job.task_id, message="扫描 GF3 SARscape 原生 _geo 结果池...")
loop = asyncio.get_running_loop()
def _progress_cb(progress: int, message: str) -> None:
try:
future = asyncio.run_coroutine_threadsafe(
task_service.update_task(job.task_id, progress=progress, message=message),
loop,
)
def _swallow_progress_error(fut):
try:
fut.result()
except Exception as exc:
logger.warning("[GF3 SARscape] progress callback failed: %s", exc)
future.add_done_callback(_swallow_progress_error)
except RuntimeError:
return
try:
async with AsyncSessionLocal() as db:
result = await standardize_gf3_sarscape_native_roots(
db,
native_dirs=native_dirs,
storage_root=storage_root,
force=bool(payload.get("force", False)),
register=bool(payload.get("register", True)),
progress_callback=_progress_cb,
)
except Exception as exc:
await task_service.update_task(
job.task_id,
status="FAILED",
progress=100,
message=f"GF3 SARscape 标准化失败: {exc}",
)
raise
message = (
"GF3 SARscape 标准化完成: "
f"发现 {int(result.get('scene_count') or 0)} 景, "
f"可转换 {int(result.get('ready_scene_count') or 0)} 景, "
f"转换 {int(result.get('converted_scenes') or 0)} 景, "
f"部分 {int(result.get('partial_scenes') or 0)} 景, "
f"失败 {int(result.get('failed_scenes') or 0)} 景, "
f"新增/更新 GeoTIFF {int(result.get('converted_assets') or 0)} 个, "
f"跳过 {int(result.get('skipped_assets') or 0)} 个, "
f"入库 {int(result.get('registered') or 0)}"
)
await task_service.update_task(job.task_id, status="COMPLETED", progress=100, message=message)
async def _handle_gf3_sarscape_produce(job: SystemJobORM) -> None:
"""Run GF3 raw archive -> SARscape native -> GeoTIFF registration chain."""
from .gf3_sarscape_production_service import (
cleanup_gf3_sarscape_native_pool,
run_gf3_sarscape_production,
)
from .gf3_standardize_service import standardize_gf3_sarscape_native_roots
if not job.task_id:
raise ValueError("GF3_SARSCAPE_PRODUCE requires task_id for progress tracking.")
payload = job.payload or {}
source_dirs = payload.get("source_dirs") or []
native_dirs = payload.get("native_dirs") or []
storage_root = payload.get("storage_root") or settings.GF3_STORAGE_DIRS
native_root = payload.get("native_root") or (native_dirs[0] if native_dirs else "")
if not source_dirs:
raise ValueError("GF3_SARSCAPE_PRODUCE: source_dirs is empty")
if not native_root:
raise ValueError("GF3_SARSCAPE_PRODUCE: native_root is empty")
if not storage_root:
raise ValueError("GF3_SARSCAPE_PRODUCE: storage_root is empty")
await task_service.start_task(job.task_id, message="GF3 SARscape production starting...")
loop = asyncio.get_running_loop()
def _progress_cb(progress: int, message: str) -> None:
try:
future = asyncio.run_coroutine_threadsafe(
task_service.update_task(job.task_id, progress=progress, message=message),
loop,
)
def _swallow_progress_error(fut):
try:
fut.result()
except Exception as exc:
logger.warning("[GF3 SARscape Produce] progress callback failed: %s", exc)
future.add_done_callback(_swallow_progress_error)
except RuntimeError:
return
def _log_cb(level: str, message: str) -> None:
try:
future = asyncio.run_coroutine_threadsafe(
task_service.add_log(job.task_id, level, message),
loop,
)
def _swallow_log_error(fut):
try:
fut.result()
except Exception as exc:
logger.warning("[GF3 SARscape Produce] log callback failed: %s", exc)
future.add_done_callback(_swallow_log_error)
except RuntimeError:
return
async def _production_keepalive() -> None:
progress = 8
while True:
await asyncio.sleep(60)
progress = min(68, progress + 1)
await task_service.update_task(
job.task_id,
progress=progress,
message="GF3 SARscape production is still running...",
)
try:
production_task = asyncio.create_task(
asyncio.to_thread(
run_gf3_sarscape_production,
source_dirs=source_dirs,
native_root=native_root,
wrapper_exe=payload.get("wrapper_exe"),
dem_path=payload.get("dem_path"),
idlrt_path=payload.get("idlrt_path"),
polarizations=payload.get("polarizations"),
archive_exts=payload.get("archive_exts") or [],
max_archives_per_run=payload.get("max_archives_per_run"),
timeout_seconds=payload.get("timeout_seconds"),
keep_extracted=payload.get("keep_extracted"),
log_callback=_log_cb,
progress_callback=_progress_cb,
)
)
keepalive_task = asyncio.create_task(_production_keepalive())
try:
production_result = await production_task
finally:
keepalive_task.cancel()
try:
await keepalive_task
except asyncio.CancelledError:
pass
standardize_result: Dict[str, Any] = {}
if bool(payload.get("auto_standardize", True)):
await task_service.update_task(
job.task_id,
progress=72,
message="GF3 SARscape production finished; standardizing native _geo outputs...",
)
async with AsyncSessionLocal() as db:
standardize_result = await standardize_gf3_sarscape_native_roots(
db,
native_dirs=native_dirs or [native_root],
storage_root=storage_root,
force=bool(payload.get("force_standardize", False)),
register=bool(payload.get("register", True)),
progress_callback=lambda pct, msg: _progress_cb(72 + int(max(0, min(100, pct)) * 0.16), msg),
)
cleanup_result: Dict[str, Any] = {}
production_ok = int(production_result.get("failed_count") or 0) == 0
standardize_ok = (
not standardize_result
or (
int(standardize_result.get("failed_assets") or 0) == 0
and int(standardize_result.get("failed_scenes") or 0) == 0
)
)
if bool(payload.get("clean_after_success", True)) and production_ok and standardize_ok:
await task_service.update_task(
job.task_id,
progress=90,
message="Cleaning GF3 SARscape intermediate files...",
)
cleanup_result = await asyncio.to_thread(
cleanup_gf3_sarscape_native_pool,
native_dirs=native_dirs or [native_root],
storage_root=storage_root,
require_standardized=bool(payload.get("cleanup_require_standardized", True)),
dry_run=bool(payload.get("cleanup_dry_run", False)),
max_scenes=payload.get("cleanup_max_scenes"),
log_callback=_log_cb,
progress_callback=lambda pct, msg: _progress_cb(90 + int(max(0, min(100, pct)) * 0.09), msg),
)
elif bool(payload.get("clean_after_success", True)):
_log_cb(
"WARNING",
"GF3 SARscape automatic cleanup skipped because production or standardization had failures.",
)
except Exception as exc:
await task_service.update_task(
job.task_id,
status="FAILED",
progress=100,
message=f"GF3 SARscape production failed: {exc}",
)
raise
failed_count = int(production_result.get("failed_count") or 0)
failed_assets = int(standardize_result.get("failed_assets") or 0)
cleanup_errors = int(cleanup_result.get("error_scene_count") or 0)
final_status = "FAILED" if failed_count or failed_assets or cleanup_errors else "COMPLETED"
message = (
"GF3 SARscape production chain finished: "
f"found={int(production_result.get('found_count') or 0)}, "
f"produced={int(production_result.get('processed_count') or 0)}, "
f"skipped={int(production_result.get('skipped_count') or 0)}, "
f"failed={failed_count}, "
f"converted_assets={int(standardize_result.get('converted_assets') or 0)}, "
f"registered={int(standardize_result.get('registered') or 0)}, "
f"cleaned_scenes={int(cleanup_result.get('cleaned_scene_count') or 0)}, "
f"cleaned_bytes={int(cleanup_result.get('bytes_deleted') or 0)}"
)
await task_service.update_task(job.task_id, status=final_status, progress=100, message=message)
async def _handle_gf3_sarscape_clean(job: SystemJobORM) -> None:
"""Clean GF3 SARscape intermediate files from native pool."""
from .gf3_sarscape_production_service import cleanup_gf3_sarscape_native_pool
if not job.task_id:
raise ValueError("GF3_SARSCAPE_CLEAN requires task_id for progress tracking.")
payload = job.payload or {}
native_dirs = payload.get("native_dirs") or []
storage_root = payload.get("storage_root") or settings.GF3_STORAGE_DIRS
if not native_dirs:
raise ValueError("GF3_SARSCAPE_CLEAN: native_dirs is empty")
await task_service.start_task(job.task_id, message="GF3 SARscape native cleanup starting...")
loop = asyncio.get_running_loop()
def _progress_cb(progress: int, message: str) -> None:
try:
future = asyncio.run_coroutine_threadsafe(
task_service.update_task(job.task_id, progress=progress, message=message),
loop,
)
def _swallow_progress_error(fut):
try:
fut.result()
except Exception as exc:
logger.warning("[GF3 SARscape Clean] progress callback failed: %s", exc)
future.add_done_callback(_swallow_progress_error)
except RuntimeError:
return
def _log_cb(level: str, message: str) -> None:
try:
future = asyncio.run_coroutine_threadsafe(
task_service.add_log(job.task_id, level, message),
loop,
)
def _swallow_log_error(fut):
try:
fut.result()
except Exception as exc:
logger.warning("[GF3 SARscape Clean] log callback failed: %s", exc)
future.add_done_callback(_swallow_log_error)
except RuntimeError:
return
try:
result = await asyncio.to_thread(
cleanup_gf3_sarscape_native_pool,
native_dirs=native_dirs,
storage_root=storage_root,
require_standardized=bool(payload.get("require_standardized", True)),
dry_run=bool(payload.get("dry_run", False)),
max_scenes=payload.get("max_scenes"),
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 SARscape native cleanup failed: {exc}",
)
raise
status = "FAILED" if int(result.get("error_scene_count") or 0) else "COMPLETED"
message = (
"GF3 SARscape native cleanup finished: "
f"scenes={int(result.get('scene_count') or 0)}, "
f"cleaned={int(result.get('cleaned_scene_count') or 0)}, "
f"skipped={int(result.get('skipped_scene_count') or 0)}, "
f"errors={int(result.get('error_scene_count') or 0)}, "
f"bytes={int(result.get('bytes_deleted') or 0)}, "
f"dry_run={bool(result.get('dry_run'))}"
)
await task_service.update_task(job.task_id, status=status, progress=100, message=message)
async def _handle_publish_dinsar_products_clean(job: SystemJobORM) -> None:
if not job.task_id:
raise ValueError("PUBLISH_DINSAR_PRODUCTS requires task_id for progress tracking.")
@@ -4671,6 +5001,9 @@ _HANDLERS = {
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_GF3_SARSCAPE_PRODUCE: _handle_gf3_sarscape_produce,
JOB_TYPE_GF3_SARSCAPE_SYNC: _handle_gf3_sarscape_sync,
JOB_TYPE_GF3_SARSCAPE_CLEAN: _handle_gf3_sarscape_clean,
JOB_TYPE_SBAS_COREGISTRATION: _handle_sbas_coregistration,
JOB_TYPE_SBAS_RDC_DEM: _handle_sbas_rdc_dem,
JOB_TYPE_SBAS_INTERFEROGRAMS: _handle_sbas_interferograms,
@@ -262,6 +262,15 @@ def _build_root_specs_from_settings() -> List[RootSpec]:
scan_mode="scene_directory",
)
)
specs.extend(
_iter_multi_root_specs(
env_var="GF3_SARSCAPE_NATIVE_DIRS",
paths=split_env_paths(settings.GF3_SARSCAPE_NATIVE_DIRS),
root_role="source_pool_gf3_sarscape_native",
display_prefix="GF3 SARscape Native Pool",
scan_mode="scene_directory",
)
)
specs.extend(
_iter_single_root_specs(
env_var="SAR_ANALYSIS_READY_ROOT",
@@ -20,6 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from ..config import settings
from ..models import RadarDataORM, SARSceneGeoORM
from ..utils import normalize_satellite_family
from .image_service import image_service
_SAFE_TEXT_RE = re.compile(r"[^0-9A-Za-z._-]+")
_POLARIZATION_PRIORITY = ("HH", "VV", "HV", "VH")
@@ -82,7 +83,25 @@ def scene_analysis_dir(
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)
json.dump(_json_safe(payload), stream, ensure_ascii=False, indent=2, default=str, allow_nan=False)
def _json_safe(value: Any) -> Any:
if isinstance(value, float):
return value if math.isfinite(value) else None
if isinstance(value, dict):
return {key: _json_safe(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_json_safe(item) for item in value]
return value
def _finite_float(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if math.isfinite(number) else None
def _link_or_copy(source: Path, target: Path) -> str:
@@ -171,7 +190,7 @@ def _raster_quality(path: Path) -> dict[str, Any]:
"top": bounds.top,
},
"transform": list(transform)[:6],
"nodata": src.nodata,
"nodata": _finite_float(src.nodata),
"valid_sample_count": int(valid.size),
"valid_sample_percent": float(valid.size / sampled.size) if sampled.size else 0.0,
}
@@ -231,6 +250,7 @@ def _build_preview_png(source: Path, target: Path) -> str | None:
if valid.size:
p2, p98 = np.nanpercentile(valid, [2, 98])
normalized = np.clip((data - p2) / max(p98 - p2, 1e-6), 0, 1)
normalized = np.where(np.isfinite(normalized), normalized, 0)
gray = (normalized * 255).astype("uint8")
else:
gray = np.zeros(data.shape, dtype="uint8")
@@ -240,6 +260,31 @@ def _build_preview_png(source: Path, target: Path) -> str | None:
return str(target)
def _build_preview_from_existing(source: Path | None, target: Path) -> str | None:
if source is None or not source.is_file():
return None
try:
from PIL import Image
except Exception:
return None
target.parent.mkdir(parents=True, exist_ok=True)
try:
with Image.open(source) as img:
preview = img.copy()
resampling = getattr(getattr(Image, "Resampling", Image), "LANCZOS")
preview.thumbnail((1600, 1600), resampling)
if preview.mode in {"1", "I", "I;16", "F"}:
preview = preview.convert("L")
elif preview.mode not in {"L", "LA", "RGB", "RGBA"}:
preview = preview.convert("RGB")
preview = image_service.make_edge_dark_transparent(preview)
preview.save(target, "PNG")
return str(target)
except Exception:
return None
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()
@@ -262,6 +307,7 @@ async def register_analysis_ready_tif(
backscatter_unit: str,
polarization: str | None = None,
metadata: dict[str, Any] | None = None,
preview_source_path: str | None = None,
copy_mode: str = "link_or_copy",
) -> dict[str, Any]:
source = Path(os.path.normpath(str(source_tif_path or "").strip()))
@@ -283,7 +329,10 @@ async def register_analysis_ready_tif(
transfer = _link_or_copy(source, target_tif)
quality = _raster_quality(target_tif)
preview_path = _build_preview_png(target_tif, out_dir / "preview.png")
preview_source = Path(os.path.normpath(preview_source_path)) if preview_source_path else None
preview_path = _build_preview_from_existing(preview_source, out_dir / "preview.png")
if not preview_path:
preview_path = _build_preview_png(target_tif, out_dir / "preview.png")
manifest = {
"scene_id": scene.id,
"radar_data_id": scene.radar_data_id,
@@ -296,6 +345,7 @@ async def register_analysis_ready_tif(
"backscatter_unit": backscatter_unit,
"polarization": polarization,
"transfer": transfer,
"preview_source_path": str(preview_source) if preview_path and preview_source else None,
"metadata": metadata or {},
"quality": quality,
}
@@ -309,14 +359,9 @@ async def register_analysis_ready_tif(
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.analysis_nodata_value = _finite_float(quality.get("nodata")) or float(settings.SAR_ANALYSIS_NODATA_VALUE)
scene.analysis_metadata_json = _json_safe({**(metadata or {}), "manifest_path": str(out_dir / "manifest.json")})
scene.analysis_quality_json = _json_safe(quality)
scene.pixel_size_m = _pixel_size_m_from_quality(quality) or scene.pixel_size_m
scene.status = "DONE"
scene.error_msg = None
+159 -21
View File
@@ -10,6 +10,7 @@ import logging
import math
import os
import struct
from pathlib import Path
from typing import Any, Dict, Optional, Tuple
import numpy as np
@@ -21,6 +22,65 @@ logger = logging.getLogger(__name__)
# SRTM HGT helpers
# ---------------------------------------------------------------------------
def _otsu_threshold(values: np.ndarray, bins: int = 512) -> float:
"""Compute Otsu's threshold without depending on scikit-image."""
finite_values = values[np.isfinite(values)]
if finite_values.size == 0:
raise ValueError("No finite pixels available for Otsu threshold")
min_value = float(np.min(finite_values))
max_value = float(np.max(finite_values))
if min_value == max_value:
return min_value
counts, edges = np.histogram(finite_values, bins=bins, range=(min_value, max_value))
centers = (edges[:-1] + edges[1:]) / 2.0
total = float(counts.sum())
if total <= 0:
return min_value
weight_background = np.cumsum(counts).astype(np.float64)
weight_foreground = total - weight_background
cumulative_mean = np.cumsum(counts * centers)
total_mean = cumulative_mean[-1]
valid = (weight_background > 0) & (weight_foreground > 0)
between = np.zeros_like(centers, dtype=np.float64)
mean_background = np.zeros_like(centers, dtype=np.float64)
mean_foreground = np.zeros_like(centers, dtype=np.float64)
mean_background[valid] = cumulative_mean[valid] / weight_background[valid]
mean_foreground[valid] = (total_mean - cumulative_mean[valid]) / weight_foreground[valid]
between[valid] = (
weight_background[valid]
* weight_foreground[valid]
* (mean_background[valid] - mean_foreground[valid]) ** 2
)
return float(centers[int(np.argmax(between))])
def _disk_structure(radius: int) -> np.ndarray:
y, x = np.ogrid[-radius: radius + 1, -radius: radius + 1]
return (x * x + y * y) <= radius * radius
def _prepare_detection_image(img: np.ndarray, valid: np.ndarray) -> tuple[np.ndarray, np.ndarray, str]:
"""Normalize SAR values for water thresholding and keep the original mask."""
working = img.astype(np.float32, copy=True)
working[~valid] = np.nan
valid_values = working[valid]
if valid_values.size == 0:
return working, valid, "raw"
min_value = float(np.nanmin(valid_values))
p99_value = float(np.nanpercentile(valid_values, 99))
max_value = float(np.nanmax(valid_values))
if min_value >= 0.0 and (p99_value > 1.0 or max_value > 5.0):
positive_valid = valid & (working > 0)
converted = np.full_like(working, np.nan, dtype=np.float32)
converted[positive_valid] = 10.0 * np.log10(np.maximum(working[positive_valid], 1e-12))
return converted, positive_valid, "linear_to_db"
return working, valid, "raw"
def _read_hgt(filepath: str) -> np.ndarray:
"""Read a single SRTM .hgt file. Auto-detect SRTM1 (3601) vs SRTM3 (1201)."""
file_size = os.path.getsize(filepath)
@@ -96,6 +156,70 @@ def _load_srtm3_dem(
return mosaic, dem_bounds
def _candidate_dem_paths(dem_path: str) -> list[str]:
text = str(dem_path or "").strip()
if not text:
return []
path = Path(text)
candidates: list[Path] = []
for suffix in (".vrt", ".wgs84.vrt", ".tif", ".tiff", ".img", ".wgs84"):
candidate = Path(text + suffix)
if candidate.exists() and candidate not in candidates:
candidates.append(candidate)
if path.is_file() and path not in candidates:
candidates.append(path)
if path.is_dir():
for pattern in ("*.vrt", "*.tif", "*.tiff", "*.img"):
for candidate in path.glob(pattern):
if candidate not in candidates:
candidates.append(candidate)
return [str(candidate) for candidate in candidates]
def _load_raster_dem(
bounds: Tuple[float, float, float, float],
dem_path: str,
out_shape: tuple[int, int],
) -> Optional[np.ndarray]:
"""Read a DEM raster subset and resample it to the SAR image grid size."""
import rasterio
from rasterio.enums import Resampling
from rasterio.windows import from_bounds
height, width = out_shape
for candidate in _candidate_dem_paths(dem_path):
try:
with rasterio.open(candidate) as src:
if src.crs and not src.crs.is_geographic:
continue
dem_bounds = src.bounds
min_lon, min_lat, max_lon, max_lat = bounds
if (
max_lon <= dem_bounds.left
or min_lon >= dem_bounds.right
or max_lat <= dem_bounds.bottom
or min_lat >= dem_bounds.top
):
continue
window = from_bounds(min_lon, min_lat, max_lon, max_lat, transform=src.transform)
data = src.read(
1,
window=window,
out_shape=(height, width),
boundless=True,
fill_value=np.nan,
resampling=Resampling.bilinear,
).astype(np.float32)
nodata = src.nodata
if nodata is not None and np.isfinite(nodata):
data[data == np.float32(nodata)] = np.nan
logger.info("[WaterDetect] DEM raster loaded: %s", candidate)
return data
except Exception as exc:
logger.warning("[WaterDetect] DEM raster candidate skipped: %s (%s)", candidate, exc)
return None
# ---------------------------------------------------------------------------
# Core detection
# ---------------------------------------------------------------------------
@@ -119,14 +243,12 @@ def run_water_detection(
Returns dict with keys: ok, output_path, water_area_km2, water_pixel_count, otsu_threshold_db
"""
import rasterio
from scipy import ndimage
from scipy.ndimage import median_filter, gaussian_filter, label, zoom
from skimage.filters import threshold_otsu
from skimage.morphology import disk, binary_dilation, binary_erosion
from skimage.measure import regionprops
from ..config import settings
dem_dir = settings.SRTM_DEM_DIR
dem_path = settings.GF3_SARSCAPE_DEM_PATH or settings.GF3_GEO_DEM_PATH or settings.SRTM_DEM_DIR
os.makedirs(output_dir, exist_ok=True)
logger.info("[WaterDetect] Reading input: %s", geo_tiff_path)
@@ -136,6 +258,7 @@ def run_water_detection(
img = src.read(1).astype(np.float32)
transform = src.transform
crs = src.crs
nodata = src.nodata
height, width = img.shape
pixel_size_x = transform.a # degrees per pixel (x)
pixel_size_y = transform.e # degrees per pixel (y, negative)
@@ -155,17 +278,23 @@ def run_water_detection(
# Step 5: Valid mask
valid = np.isfinite(img) & (img != 0)
if nodata is not None and np.isfinite(nodata):
valid &= img != np.float32(nodata)
if np.count_nonzero(valid) < 100:
return {"ok": False, "error": "Too few valid pixels in input image"}
detection_img, valid, value_transform = _prepare_detection_image(img, valid)
logger.info("[WaterDetect] Value transform: %s", value_transform)
# Step 6: Otsu threshold on valid pixels
valid_pixels = img[valid]
thresh = threshold_otsu(valid_pixels)
valid_pixels = detection_img[valid]
thresh = _otsu_threshold(valid_pixels)
logger.info("[WaterDetect] Otsu threshold: %.4f", thresh)
# Step 7-8: Median + Gaussian filtering
filtered = median_filter(img, size=3)
filtered_input = np.where(valid, detection_img, np.nanmedian(valid_pixels)).astype(np.float32)
filtered = median_filter(filtered_input, size=3)
filtered = gaussian_filter(filtered, sigma=1.0)
# Step 9: Initial water mask
@@ -173,8 +302,11 @@ def run_water_detection(
# Step 3-4: Load and resample DEM (if available)
dem_applied = False
if dem_dir and os.path.isdir(dem_dir):
dem, dem_bounds = _load_srtm3_dem(bounds, dem_dir)
if dem_path and (os.path.isdir(dem_path) or os.path.isfile(dem_path)):
dem_resampled = _load_raster_dem(bounds, dem_path, (height, width))
dem, dem_bounds = (None, None)
if dem_resampled is None and os.path.isdir(dem_path):
dem, dem_bounds = _load_srtm3_dem(bounds, dem_path)
if dem is not None and dem_bounds is not None:
# Resample DEM to image resolution
zoom_y = height / dem.shape[0]
@@ -183,6 +315,7 @@ def run_water_detection(
# Clip to match image shape exactly
dem_resampled = dem_resampled[:height, :width]
if dem_resampled is not None:
# Step 10: DEM height constraint (0m <= DEM <= 1000m)
dem_valid = np.isfinite(dem_resampled)
height_mask = dem_valid & (dem_resampled >= 0) & (dem_resampled <= 1000)
@@ -204,14 +337,14 @@ def run_water_detection(
else:
logger.warning("[WaterDetect] DEM not available, skipping DEM constraints")
else:
logger.warning("[WaterDetect] SRTM_DEM_DIR not configured, skipping DEM constraints")
logger.warning("[WaterDetect] DEM path not configured, skipping DEM constraints")
# Step 12: Morphological processing — disk(5) dilate→erode→dilate→erode
selem = disk(5)
water = binary_dilation(water, selem)
water = binary_erosion(water, selem)
water = binary_dilation(water, selem)
water = binary_erosion(water, selem)
selem = _disk_structure(5)
water = ndimage.binary_dilation(water, structure=selem)
water = ndimage.binary_erosion(water, structure=selem)
water = ndimage.binary_dilation(water, structure=selem)
water = ndimage.binary_erosion(water, structure=selem)
# Step 13: Connected component filtering
labeled, num_features = label(water)
@@ -220,18 +353,22 @@ def run_water_detection(
pixel_area_m2 = abs(pixel_size_x) * 111320 * abs(pixel_size_y) * 111320
min_pixels_by_area = max(1, int(3000 / max(pixel_area_m2, 1)))
props = regionprops(labeled)
areas = [p.area for p in props]
if areas:
areas = ndimage.sum(
np.ones_like(labeled, dtype=np.uint8),
labeled,
index=np.arange(1, num_features + 1),
)
areas = np.asarray(areas, dtype=np.float64)
if areas.size:
median_area = float(np.median(areas))
min_area = max(min_pixels_by_area, int(median_area))
else:
min_area = min_pixels_by_area
# Remove small components
for prop in props:
if prop.area < min_area:
water[labeled == prop.label] = False
small_labels = np.where(areas < min_area)[0] + 1
if small_labels.size:
water[np.isin(labeled, small_labels)] = False
logger.info("[WaterDetect] Connected component filter: min_area=%d pixels, kept %d/%d components",
min_area, np.count_nonzero(np.unique(labeled[water])), num_features)
@@ -265,4 +402,5 @@ def run_water_detection(
"water_area_km2": round(water_area, 4),
"water_pixel_count": water_pixel_count,
"otsu_threshold_db": round(float(thresh), 4),
"value_transform": value_transform,
}