1521 lines
64 KiB
Python
1521 lines
64 KiB
Python
"""
|
||
数据导入服务
|
||
|
||
提供数据扫描和导入功能:
|
||
- 扫描雷达数据目录
|
||
- 扫描 D-InSAR 结果目录
|
||
- 扫描灾害点数据
|
||
|
||
优化策略:
|
||
- 使用 geopandas/pyogrio 读取 Shapefile
|
||
- 利用 PostGIS 空间索引
|
||
- 支持增量扫描
|
||
"""
|
||
import os
|
||
import json
|
||
import asyncio
|
||
import hashlib
|
||
import re
|
||
from typing import List, Dict, Optional, Tuple, Any
|
||
from datetime import datetime
|
||
from shapely.geometry import Point, Polygon, shape, mapping
|
||
from shapely.ops import unary_union
|
||
import geopandas as gpd
|
||
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
from sqlalchemy.future import select
|
||
from sqlalchemy import func, and_, text, update, delete
|
||
from sqlalchemy.dialects.postgresql import insert as pg_insert
|
||
from geoalchemy2.shape import from_shape
|
||
from geoalchemy2.functions import ST_Intersects, ST_Intersection, ST_Area
|
||
|
||
from ..config import settings
|
||
from ..models import (
|
||
RadarDataORM, RadarData, DinsarResultORM, HazardPointORM, HazardPoint, ScanStateORM
|
||
)
|
||
from ..utils import (
|
||
get_parser,
|
||
RADAR_PARSERS,
|
||
find_xml_file,
|
||
normalize_satellite_family,
|
||
parse_xml_metadata,
|
||
)
|
||
from .task_service import task_service
|
||
from .image_service import image_service
|
||
from .orbit_converter import get_source_orbit_inventory, sync_orbit_pools
|
||
from .pairing_state_service import pairing_state_service
|
||
|
||
|
||
def _safe_mtime(path: str) -> float:
|
||
try:
|
||
return max(os.path.getmtime(path), os.path.getctime(path))
|
||
except OSError:
|
||
return 0.0
|
||
|
||
|
||
_DATE_RE = re.compile(r"^\d{8}$")
|
||
|
||
|
||
def _valid_imaging_date(value: Optional[str]) -> bool:
|
||
return bool(value and _DATE_RE.match(value))
|
||
|
||
|
||
def _extract_date_from_text(value: Optional[str]) -> Optional[str]:
|
||
if not value:
|
||
return None
|
||
digits = re.sub(r"\D", "", value)
|
||
if len(digits) >= 8:
|
||
return digits[:8]
|
||
return None
|
||
|
||
|
||
def _text_or_none(value: Any) -> Optional[str]:
|
||
if value is None:
|
||
return None
|
||
text_value = str(value).strip()
|
||
return text_value or None
|
||
|
||
|
||
def _bool_or_none(value: Any) -> Optional[bool]:
|
||
if value is None or isinstance(value, bool):
|
||
return value
|
||
text_value = str(value).strip().lower()
|
||
if text_value in {"1", "true", "yes", "y"}:
|
||
return True
|
||
if text_value in {"0", "false", "no", "n"}:
|
||
return False
|
||
return None
|
||
|
||
|
||
def _build_insar_source_readiness(
|
||
meta: Dict[str, Any],
|
||
coverage_polygon: Optional[List[Tuple[float, float]]],
|
||
) -> Tuple[bool, Optional[str]]:
|
||
reasons: List[str] = []
|
||
if not coverage_polygon or len(coverage_polygon) < 3:
|
||
reasons.append("missing_footprint")
|
||
if not _valid_imaging_date(_text_or_none(meta.get("imaging_date"))):
|
||
reasons.append("missing_date")
|
||
for field_name, reason in (
|
||
("orbit_direction", "missing_orbit_direction"),
|
||
("imaging_mode", "missing_imaging_mode"),
|
||
("polarization", "missing_polarization"),
|
||
("satellite_family", "missing_satellite_family"),
|
||
):
|
||
if not _text_or_none(meta.get(field_name)):
|
||
reasons.append(reason)
|
||
|
||
geocoded_flag = _bool_or_none(meta.get("geocoded_flag"))
|
||
if geocoded_flag is True:
|
||
reasons.append("geocoded_product")
|
||
|
||
complex_tokens = {
|
||
_text_or_none(meta.get("image_data_type")),
|
||
_text_or_none(meta.get("product_type")),
|
||
_text_or_none(meta.get("source_product_token")),
|
||
_text_or_none(meta.get("product_variant")),
|
||
}
|
||
normalized_tokens = {str(token).strip().upper() for token in complex_tokens if token}
|
||
is_complex_source = bool(normalized_tokens.intersection({"COMPLEX", "SLC", "SSC"}))
|
||
if not is_complex_source:
|
||
reasons.append("not_complex_source")
|
||
|
||
if reasons:
|
||
return False, ";".join(reasons)
|
||
return True, None
|
||
|
||
|
||
def _iter_dirs(root: str, last_mtime: float):
|
||
stack = [root]
|
||
while stack:
|
||
current = stack.pop()
|
||
try:
|
||
with os.scandir(current) as it:
|
||
for entry in it:
|
||
try:
|
||
if entry.is_dir(follow_symlinks=False):
|
||
try:
|
||
mtime = entry.stat().st_mtime
|
||
except OSError:
|
||
mtime = 0.0
|
||
if last_mtime and mtime <= last_mtime:
|
||
continue
|
||
stack.append(entry.path)
|
||
yield entry
|
||
except OSError:
|
||
continue
|
||
except OSError:
|
||
continue
|
||
|
||
|
||
def _iter_files(root: str, suffix: str, last_mtime: float):
|
||
stack = [root]
|
||
suffix = suffix.lower()
|
||
while stack:
|
||
current = stack.pop()
|
||
try:
|
||
with os.scandir(current) as it:
|
||
for entry in it:
|
||
try:
|
||
if entry.is_dir(follow_symlinks=False):
|
||
try:
|
||
mtime = entry.stat().st_mtime
|
||
except OSError:
|
||
mtime = 0.0
|
||
if last_mtime and mtime <= last_mtime:
|
||
continue
|
||
stack.append(entry.path)
|
||
elif entry.is_file(follow_symlinks=False):
|
||
if entry.name.lower().endswith(suffix):
|
||
yield entry
|
||
except OSError:
|
||
continue
|
||
except OSError:
|
||
continue
|
||
|
||
|
||
_DINSAR_ISCE2_DISP_RE = re.compile(r"^.+_disp\.(?:tif|tiff)$", re.IGNORECASE)
|
||
_DINSAR_ISCE2_SKIP_RE = re.compile(r"^.+_disp_full\.(?:tif|tiff)$", re.IGNORECASE)
|
||
|
||
|
||
def _iter_dinsar_result_candidates(root: str):
|
||
stack = [root]
|
||
while stack:
|
||
current = stack.pop()
|
||
try:
|
||
with os.scandir(current) as it:
|
||
for entry in it:
|
||
try:
|
||
if entry.is_dir(follow_symlinks=False):
|
||
stack.append(entry.path)
|
||
continue
|
||
if not entry.is_file(follow_symlinks=False):
|
||
continue
|
||
|
||
lower_name = entry.name.lower()
|
||
if lower_name.endswith(".hdr"):
|
||
base_name, _ = os.path.splitext(entry.name)
|
||
if not base_name.lower().endswith("_disp"):
|
||
continue
|
||
data_file_path = os.path.join(os.path.dirname(entry.path), base_name)
|
||
if not os.path.exists(data_file_path):
|
||
continue
|
||
source_type = "envi"
|
||
record_name = base_name
|
||
elif lower_name.endswith((".tif", ".tiff")):
|
||
if _DINSAR_ISCE2_SKIP_RE.match(entry.name):
|
||
continue
|
||
if not _DINSAR_ISCE2_DISP_RE.match(entry.name):
|
||
continue
|
||
data_file_path = entry.path
|
||
record_name, _ = os.path.splitext(entry.name)
|
||
source_type = "isce2"
|
||
else:
|
||
continue
|
||
|
||
try:
|
||
stat = entry.stat()
|
||
scan_ts = max(stat.st_mtime, stat.st_ctime)
|
||
except OSError:
|
||
scan_ts = 0.0
|
||
|
||
yield {
|
||
"name": record_name,
|
||
"file_path": data_file_path,
|
||
"scan_path": entry.path,
|
||
"scan_ts": scan_ts,
|
||
"source_type": source_type,
|
||
}
|
||
except OSError:
|
||
continue
|
||
except OSError:
|
||
continue
|
||
|
||
|
||
def _chunked(items: List[Any], size: int):
|
||
for i in range(0, len(items), size):
|
||
yield items[i:i + size]
|
||
|
||
|
||
_RADAR_PREVIEW_EXTENSIONS = (".jpg", ".jpeg", ".png", ".webp", ".bmp", ".tif", ".tiff")
|
||
_RADAR_PREVIEW_KEYWORDS = ("quicklook", "quick-look", "preview", "browse", "thumbnail", "thumb", "overview")
|
||
_RADAR_CACHE_NAME_RE = re.compile(r"[^A-Za-z0-9._-]+")
|
||
|
||
|
||
def _sanitize_cache_name(name: str) -> str:
|
||
cleaned = _RADAR_CACHE_NAME_RE.sub("_", name or "")
|
||
cleaned = cleaned.strip("._")
|
||
return cleaned[:48] or "scene"
|
||
|
||
|
||
def _build_cache_digest(unique_id: str, file_path: str) -> str:
|
||
return hashlib.sha1((unique_id or file_path).encode("utf-8", errors="ignore")).hexdigest()[:16]
|
||
|
||
|
||
def _build_radar_cache_filename(unique_id: str, file_path: str) -> str:
|
||
digest = _build_cache_digest(unique_id, file_path)
|
||
base = _sanitize_cache_name(os.path.basename(file_path))
|
||
return f"RID_{digest}_{base}.webp"
|
||
|
||
|
||
def _build_radar_geo_cache_filename(unique_id: str, file_path: str, cache_version: str) -> str:
|
||
digest = _build_cache_digest(unique_id, file_path)
|
||
base = _sanitize_cache_name(os.path.basename(file_path))
|
||
safe_version = _sanitize_cache_name(cache_version or "v")
|
||
return f"RGID_{safe_version}_{digest}_{base}.webp"
|
||
|
||
|
||
def extract_geotiff_bounds(tiff_path: str) -> Optional[List[Tuple[float, float]]]:
|
||
"""Extract coverage polygon from a GeoTIFF file using GDAL.
|
||
|
||
Returns a list of (lon, lat) tuples forming a closed polygon (5 points),
|
||
or None if the file cannot be read or has no valid geotransform.
|
||
"""
|
||
try:
|
||
from osgeo import gdal, osr
|
||
ds = gdal.Open(tiff_path, gdal.GA_ReadOnly)
|
||
if ds is None:
|
||
return None
|
||
gt = ds.GetGeoTransform()
|
||
if gt is None or gt == (0, 1, 0, 0, 0, 1):
|
||
ds = None
|
||
return None
|
||
w, h = ds.RasterXSize, ds.RasterYSize
|
||
|
||
# Compute 4 corners in the raster's CRS
|
||
corners_xy = [
|
||
(gt[0], gt[3]), # top-left
|
||
(gt[0] + w * gt[1], gt[3] + w * gt[4]), # top-right
|
||
(gt[0] + w * gt[1] + h * gt[2], gt[3] + w * gt[4] + h * gt[5]), # bottom-right
|
||
(gt[0] + h * gt[2], gt[3] + h * gt[5]), # bottom-left
|
||
]
|
||
|
||
# Transform to WGS84 if necessary
|
||
srs = osr.SpatialReference()
|
||
srs.ImportFromWkt(ds.GetProjection())
|
||
ds = None
|
||
|
||
wgs84 = osr.SpatialReference()
|
||
wgs84.ImportFromEPSG(4326)
|
||
|
||
if srs.IsSame(wgs84):
|
||
polygon = [(x, y) for x, y in corners_xy]
|
||
else:
|
||
srs.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
|
||
wgs84.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
|
||
transform = osr.CoordinateTransformation(srs, wgs84)
|
||
polygon = []
|
||
for x, y in corners_xy:
|
||
lon, lat, _ = transform.TransformPoint(x, y)
|
||
polygon.append((lon, lat))
|
||
|
||
polygon.append(polygon[0]) # close the ring
|
||
return polygon
|
||
except Exception:
|
||
return None
|
||
|
||
|
||
class DataService:
|
||
@staticmethod
|
||
async def _get_scan_state(db: AsyncSession, data_type: str, root_path: str):
|
||
result = await db.execute(
|
||
select(ScanStateORM).where(
|
||
ScanStateORM.data_type == data_type,
|
||
ScanStateORM.root_path == root_path
|
||
)
|
||
)
|
||
return result.scalar_one_or_none()
|
||
|
||
@staticmethod
|
||
async def _upsert_scan_state(db: AsyncSession, data_type: str, root_path: str, last_scan_mtime: float):
|
||
stmt = pg_insert(ScanStateORM).values({
|
||
"data_type": data_type,
|
||
"root_path": root_path,
|
||
"last_scan_mtime": last_scan_mtime,
|
||
})
|
||
stmt = stmt.on_conflict_do_update(
|
||
index_elements=['data_type', 'root_path'],
|
||
set_={
|
||
"last_scan_mtime": last_scan_mtime,
|
||
"last_scan_at": datetime.now(),
|
||
}
|
||
)
|
||
await db.execute(stmt)
|
||
|
||
@staticmethod
|
||
def get_radar_raw_cache_path(unique_id: str, file_path: str) -> str:
|
||
filename = _build_radar_cache_filename(unique_id, file_path)
|
||
return os.path.join(settings.RADAR_RAW_CACHE_DIR, filename)
|
||
|
||
@staticmethod
|
||
def get_radar_geo_cache_path(unique_id: str, file_path: str) -> str:
|
||
filename = _build_radar_geo_cache_filename(
|
||
unique_id=unique_id,
|
||
file_path=file_path,
|
||
cache_version=settings.RADAR_GEO_CACHE_VERSION,
|
||
)
|
||
return os.path.join(settings.RADAR_GEO_CACHE_DIR, filename)
|
||
|
||
@staticmethod
|
||
def get_radar_cache_path(unique_id: str, file_path: str) -> str:
|
||
return DataService.get_radar_raw_cache_path(unique_id, file_path)
|
||
|
||
@staticmethod
|
||
def get_dinsar_cache_filename(record_id: int, record_name: str) -> str:
|
||
safe_name = _sanitize_cache_name(record_name)
|
||
return f"ID_{record_id}_{safe_name}.webp"
|
||
|
||
@staticmethod
|
||
def get_dinsar_cache_path(record_id: int, record_name: str) -> str:
|
||
filename = DataService.get_dinsar_cache_filename(record_id, record_name)
|
||
cache_dir = os.path.realpath(settings.DINSAR_CACHE_DIR)
|
||
target = os.path.realpath(os.path.join(cache_dir, filename))
|
||
if not target.startswith(cache_dir + os.sep) and target != cache_dir:
|
||
raise ValueError(f"Path traversal detected: {record_name!r}")
|
||
return target
|
||
|
||
@staticmethod
|
||
def get_dinsar_manifest_path() -> str:
|
||
return os.path.join(settings.DINSAR_CACHE_DIR, "cache_manifest.json")
|
||
|
||
@staticmethod
|
||
def find_radar_preview_source(scene_dir: str) -> Optional[str]:
|
||
if not scene_dir or not os.path.isdir(scene_dir):
|
||
return None
|
||
|
||
candidates: List[Tuple[int, int, int, int, str]] = []
|
||
for root, _, files in os.walk(scene_dir):
|
||
rel = os.path.relpath(root, scene_dir)
|
||
depth = 0 if rel == "." else rel.count(os.sep) + 1
|
||
if depth > 5:
|
||
continue
|
||
|
||
for name in files:
|
||
lower_name = name.lower()
|
||
if not lower_name.endswith(_RADAR_PREVIEW_EXTENSIONS):
|
||
continue
|
||
|
||
path = os.path.join(root, name)
|
||
root_lower = root.lower()
|
||
keyword_score = 0 if any(key in lower_name for key in _RADAR_PREVIEW_KEYWORDS) else 1
|
||
if lower_name == "quick-look.png":
|
||
keyword_score = -3
|
||
elif lower_name == "quicklook.png":
|
||
keyword_score = -2
|
||
elif lower_name.startswith("quick-look.") or lower_name.startswith("quicklook."):
|
||
keyword_score = min(keyword_score, -1)
|
||
if f"{os.sep}preview" in root_lower:
|
||
keyword_score -= 1
|
||
if f"{os.sep}icons" in root_lower:
|
||
keyword_score += 2
|
||
ext_score = 0 if lower_name.endswith((".jpg", ".jpeg")) else 1
|
||
try:
|
||
size_score = -os.path.getsize(path)
|
||
except OSError:
|
||
size_score = 0
|
||
candidates.append((keyword_score, depth, ext_score, size_score, path))
|
||
|
||
if not candidates:
|
||
return None
|
||
|
||
candidates.sort()
|
||
return candidates[0][-1]
|
||
|
||
@staticmethod
|
||
def get_radar_source_corner_mapping(scene_dir: str) -> Optional[Dict[str, Any]]:
|
||
if not scene_dir or not os.path.isdir(scene_dir):
|
||
return None
|
||
xml_file_path = find_xml_file(scene_dir)
|
||
if not xml_file_path:
|
||
return None
|
||
_, xml_meta = parse_xml_metadata(xml_file_path)
|
||
if not xml_meta:
|
||
return None
|
||
corner_mapping = xml_meta.get("corner_pixel_mapping")
|
||
if isinstance(corner_mapping, dict):
|
||
return corner_mapping
|
||
return None
|
||
|
||
@staticmethod
|
||
def _normalize_coverage_polygon(coverage_polygon: Any) -> Optional[List[Tuple[float, float]]]:
|
||
if isinstance(coverage_polygon, list):
|
||
points: List[Tuple[float, float]] = []
|
||
for point in coverage_polygon:
|
||
if isinstance(point, (list, tuple)) and len(point) >= 2:
|
||
points.append((float(point[0]), float(point[1])))
|
||
return points or None
|
||
if isinstance(coverage_polygon, dict):
|
||
coordinates = coverage_polygon.get("coordinates")
|
||
if isinstance(coordinates, list) and coordinates:
|
||
ring = coordinates[0]
|
||
if isinstance(ring, list):
|
||
points = []
|
||
for point in ring:
|
||
if isinstance(point, (list, tuple)) and len(point) >= 2:
|
||
points.append((float(point[0]), float(point[1])))
|
||
return points or None
|
||
return None
|
||
|
||
|
||
"""
|
||
数据导入服务
|
||
|
||
负责扫描和导入各类数据到数据库。
|
||
"""
|
||
|
||
@staticmethod
|
||
async def scan_radar_data(
|
||
db: AsyncSession,
|
||
radar_dirs: List[str],
|
||
orbit_dir: Optional[str] = None,
|
||
task_id: Optional[str] = None,
|
||
progress_base: int = 0,
|
||
progress_span: int = 100
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
异步扫描雷达和轨道数据目录,解析文件,并将元数据存入数据库。
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
radar_dirs: 雷达数据目录列表
|
||
orbit_dir: 精轨数据目录
|
||
task_id: 任务 ID(用于进度更新)
|
||
|
||
Returns:
|
||
扫描结果字典
|
||
"""
|
||
def update_progress(msg: str, prog: int):
|
||
scaled = prog
|
||
if task_id:
|
||
scaled = progress_base + int((prog / 100) * progress_span)
|
||
scaled = min(progress_base + progress_span, max(progress_base, scaled))
|
||
asyncio.create_task(task_service.update_task(task_id, message=msg, progress=scaled))
|
||
print(f" [扫描进度] {scaled}% - {msg}")
|
||
|
||
os.makedirs(settings.RADAR_RAW_CACHE_DIR, exist_ok=True)
|
||
os.makedirs(settings.RADAR_GEO_CACHE_DIR, exist_ok=True)
|
||
|
||
update_progress("正在扫描精轨数据...", 5)
|
||
|
||
# 1. Pre-process all orbit data
|
||
orbit_files_map: Dict[Tuple[str, str], str] = {}
|
||
orbit_inventory = {"errors": [], "duplicate_count": 0, "files": {}}
|
||
invalid_orbit_stems = set()
|
||
if orbit_dir:
|
||
orbit_inventory = await asyncio.to_thread(
|
||
get_source_orbit_inventory,
|
||
orbit_dir,
|
||
True,
|
||
)
|
||
for item in orbit_inventory.get("files", {}).values():
|
||
orbit_files_map[(item["satellite"], item["date"])] = item["path"]
|
||
for err in orbit_inventory.get("errors", []):
|
||
print(f"警告: 精轨目录扫描异常: {err}")
|
||
if orbit_inventory.get("duplicate_count", 0):
|
||
print(f"警告: 精轨目录存在 {orbit_inventory['duplicate_count']} 个重复 stem,已按最新文件优先。")
|
||
else:
|
||
print("未配置精轨数据目录,跳过精轨扫描。")
|
||
|
||
# 1b. 同步精轨到本地引擎池(ENVI / ISCE2)
|
||
if orbit_dir and orbit_files_map:
|
||
update_progress("正在同步精轨到本地引擎池...", 8)
|
||
try:
|
||
sync_result = await asyncio.to_thread(
|
||
sync_orbit_pools,
|
||
orbit_dir,
|
||
settings.ORBIT_POOL_ENVI,
|
||
settings.ORBIT_POOL_ISCE2,
|
||
settings.ORBIT_POOL_LANDSAR,
|
||
)
|
||
envi_new = len(sync_result.get("envi", {}).get("copied", []))
|
||
envi_updated = len(sync_result.get("envi", {}).get("updated", []))
|
||
isce2_new = len(sync_result.get("isce2", {}).get("converted", []))
|
||
isce2_updated = len(sync_result.get("isce2", {}).get("reconverted", []))
|
||
print(
|
||
f" [精轨同步] ENVI 新增 {envi_new}、刷新 {envi_updated},"
|
||
f"ISCE2 转换 {isce2_new}、重转 {isce2_updated}"
|
||
)
|
||
invalid_orbit_stems = {
|
||
item.get("name")
|
||
for item in sync_result.get("invalid_sources", [])
|
||
if item.get("name")
|
||
}
|
||
if invalid_orbit_stems:
|
||
print(f" [精轨同步] 发现 {len(invalid_orbit_stems)} 个坏源精轨,已禁止入池/入库。")
|
||
orbit_files_map = {
|
||
key: value
|
||
for key, value in orbit_files_map.items()
|
||
if f"{key[0]}_GpsData_GAS_C_{key[1]}" not in invalid_orbit_stems
|
||
}
|
||
sync_errors = (
|
||
sync_result.get("source", {}).get("errors", [])
|
||
+ [item.get("error", "") for item in sync_result.get("envi", {}).get("errors", [])]
|
||
+ [item.get("error", "") for item in sync_result.get("isce2", {}).get("errors", [])]
|
||
)
|
||
if task_id and sync_errors:
|
||
await task_service.add_log(
|
||
task_id,
|
||
"WARN",
|
||
(
|
||
"精轨同步存在异常: "
|
||
f"源目录异常 {len(sync_result.get('source', {}).get('errors', []))} 项, "
|
||
f"ENVI 复制异常 {len(sync_result.get('envi', {}).get('errors', []))} 项, "
|
||
f"ISCE2 转换异常 {len(sync_result.get('isce2', {}).get('errors', []))} 项"
|
||
),
|
||
)
|
||
for item in sync_result.get("isce2", {}).get("errors", [])[:20]:
|
||
await task_service.add_log(
|
||
task_id,
|
||
"WARN",
|
||
f"ISCE2 转换失败: {item.get('file')} -> {item.get('error')}",
|
||
)
|
||
if task_id and invalid_orbit_stems:
|
||
await task_service.add_log(
|
||
task_id,
|
||
"WARN",
|
||
f"发现 {len(invalid_orbit_stems)} 个坏源精轨,已跳过入池与轨道关联。",
|
||
)
|
||
for item in sync_result.get("invalid_sources", [])[:20]:
|
||
await task_service.add_log(
|
||
task_id,
|
||
"WARN",
|
||
(
|
||
f"坏源精轨: {item.get('name')} -> {item.get('error')}"
|
||
f" (NUL={item.get('nul_byte_count', 0)}, tail_nul={item.get('tail_has_nul_bytes', False)})"
|
||
),
|
||
)
|
||
except Exception as _sync_exc:
|
||
print(f" [精轨同步] 警告:同步失败(不影响入库):{_sync_exc}")
|
||
elif task_id and orbit_inventory.get("errors"):
|
||
await task_service.add_log(
|
||
task_id,
|
||
"WARN",
|
||
f"精轨目录扫描异常: {orbit_inventory['errors'][0]}",
|
||
)
|
||
|
||
# 2. Process all radar data folders from multiple directories
|
||
update_progress("正在扫描雷达数据...", 10)
|
||
processed_scenes = 0
|
||
|
||
# 预先计算公共根目录
|
||
common_base = None
|
||
if radar_dirs:
|
||
try:
|
||
common_base = os.path.commonpath([os.path.abspath(d) for d in radar_dirs])
|
||
except ValueError:
|
||
common_base = None
|
||
|
||
total_progress = 80
|
||
scan_state_updates = []
|
||
radar_cache_candidates: Dict[str, Dict[str, str]] = {}
|
||
for idx, radar_dir in enumerate(radar_dirs):
|
||
radar_dir_abs = os.path.abspath(radar_dir)
|
||
state = await DataService._get_scan_state(db, "radar", radar_dir_abs)
|
||
last_mtime = state.last_scan_mtime if state else 0.0
|
||
max_mtime = last_mtime
|
||
if not os.path.exists(radar_dir):
|
||
print(f"警告: 雷达数据目录不存在,已跳过: {radar_dir}")
|
||
continue
|
||
root_mtime = _safe_mtime(radar_dir_abs)
|
||
if last_mtime and root_mtime <= last_mtime:
|
||
scan_state_updates.append(("radar", radar_dir_abs, last_mtime))
|
||
continue
|
||
|
||
for entry in _iter_dirs(radar_dir, last_mtime):
|
||
folder_name = entry.name
|
||
parsed_radar = get_parser(folder_name, RADAR_PARSERS)
|
||
if not parsed_radar:
|
||
continue
|
||
|
||
radar_folder_path = entry.path
|
||
try:
|
||
stat = entry.stat()
|
||
scan_ts = max(stat.st_mtime, stat.st_ctime)
|
||
except OSError:
|
||
scan_ts = 0
|
||
if scan_ts <= last_mtime:
|
||
continue
|
||
if scan_ts > max_mtime:
|
||
max_mtime = scan_ts
|
||
print(f" -> 正在处理: {folder_name}")
|
||
|
||
xml_file_path = find_xml_file(radar_folder_path)
|
||
if not xml_file_path:
|
||
continue
|
||
|
||
coverage_polygon, xml_meta = parse_xml_metadata(xml_file_path)
|
||
|
||
if not coverage_polygon or len(coverage_polygon) < 3:
|
||
continue
|
||
|
||
name_meta = parsed_radar or {}
|
||
merged_meta = dict(name_meta)
|
||
if xml_meta:
|
||
prefer_name_keys = {"product_unique_id"}
|
||
for key, value in xml_meta.items():
|
||
if value in (None, ""):
|
||
continue
|
||
if key in prefer_name_keys and merged_meta.get(key):
|
||
continue
|
||
merged_meta[key] = value
|
||
|
||
satellite = _text_or_none(merged_meta.get("satellite"))
|
||
imaging_date = _text_or_none(merged_meta.get("imaging_date"))
|
||
imaging_mode = _text_or_none(merged_meta.get("imaging_mode"))
|
||
polarization = _text_or_none(merged_meta.get("polarization"))
|
||
orbit_direction = _text_or_none(merged_meta.get("orbit_direction"))
|
||
satellite_mode = _text_or_none(merged_meta.get("satellite_mode"))
|
||
receiving_station = _text_or_none(merged_meta.get("receiving_station"))
|
||
orbit_circle = _text_or_none(merged_meta.get("orbit_circle"))
|
||
scene_center_lon = merged_meta.get("scene_center_lon")
|
||
scene_center_lat = merged_meta.get("scene_center_lat")
|
||
acquisition_time_utc = _text_or_none(merged_meta.get("acquisition_time_utc"))
|
||
product_type = _text_or_none(merged_meta.get("product_type"))
|
||
source_product_token = _text_or_none(merged_meta.get("source_product_token"))
|
||
image_data_type = _text_or_none(merged_meta.get("image_data_type"))
|
||
image_data_format = _text_or_none(merged_meta.get("image_data_format"))
|
||
product_variant = _text_or_none(merged_meta.get("product_variant"))
|
||
product_level = _text_or_none(merged_meta.get("product_level"))
|
||
product_unique_id = _text_or_none(merged_meta.get("product_unique_id"))
|
||
satellite_family = normalize_satellite_family(
|
||
_text_or_none(merged_meta.get("satellite_family")) or satellite
|
||
)
|
||
look_direction = _text_or_none(merged_meta.get("look_direction"))
|
||
if look_direction:
|
||
look_direction = look_direction.upper()
|
||
geocoded_flag = _bool_or_none(merged_meta.get("geocoded_flag"))
|
||
|
||
if not satellite:
|
||
continue
|
||
if not imaging_date and acquisition_time_utc:
|
||
imaging_date = _extract_date_from_text(acquisition_time_utc)
|
||
|
||
if not _valid_imaging_date(imaging_date):
|
||
print(f"警告: 影像日期格式无效,已跳过: {imaging_date} ({radar_folder_path})")
|
||
continue
|
||
has_orbit_data = (satellite, imaging_date) in orbit_files_map
|
||
orbit_file_path = orbit_files_map.get((satellite, imaging_date))
|
||
|
||
envi_import_path = os.path.join(radar_folder_path, 'envi_import')
|
||
is_envi_processed = os.path.isdir(envi_import_path) and bool(os.listdir(envi_import_path))
|
||
|
||
lons = [p[0] for p in coverage_polygon]
|
||
lats = [p[1] for p in coverage_polygon]
|
||
|
||
# 生成唯一 ID
|
||
if common_base:
|
||
try:
|
||
unique_id = os.path.relpath(radar_folder_path, start=common_base)
|
||
except ValueError:
|
||
unique_id = radar_folder_path
|
||
else:
|
||
unique_id = radar_folder_path
|
||
|
||
# Data to be inserted or updated
|
||
poly = Polygon(coverage_polygon)
|
||
if not poly.is_valid:
|
||
poly = poly.buffer(0)
|
||
if (scene_center_lon is None or scene_center_lat is None) and poly.is_valid:
|
||
scene_center_lon = scene_center_lon if scene_center_lon is not None else poly.centroid.x
|
||
scene_center_lat = scene_center_lat if scene_center_lat is not None else poly.centroid.y
|
||
|
||
readiness_meta = {
|
||
"satellite_family": satellite_family,
|
||
"imaging_date": imaging_date,
|
||
"imaging_mode": imaging_mode,
|
||
"orbit_direction": orbit_direction,
|
||
"polarization": polarization,
|
||
"product_type": product_type,
|
||
"source_product_token": source_product_token,
|
||
"image_data_type": image_data_type,
|
||
"product_variant": product_variant,
|
||
"geocoded_flag": geocoded_flag,
|
||
}
|
||
insar_source_ready, insar_source_reason = _build_insar_source_readiness(
|
||
readiness_meta,
|
||
coverage_polygon,
|
||
)
|
||
|
||
data_to_upsert = {
|
||
"unique_id": unique_id,
|
||
"satellite": satellite,
|
||
"satellite_family": satellite_family,
|
||
"imaging_date": imaging_date,
|
||
"imaging_mode": imaging_mode,
|
||
"orbit_direction": orbit_direction,
|
||
"polarization": polarization,
|
||
"satellite_mode": satellite_mode,
|
||
"receiving_station": receiving_station,
|
||
"orbit_circle": orbit_circle,
|
||
"scene_center_lon": scene_center_lon,
|
||
"scene_center_lat": scene_center_lat,
|
||
"acquisition_time_utc": acquisition_time_utc,
|
||
"product_type": product_type,
|
||
"source_product_token": source_product_token,
|
||
"image_data_type": image_data_type,
|
||
"image_data_format": image_data_format,
|
||
"product_variant": product_variant,
|
||
"product_level": product_level,
|
||
"product_unique_id": product_unique_id,
|
||
"look_direction": look_direction,
|
||
"geocoded_flag": geocoded_flag,
|
||
"insar_source_ready": insar_source_ready,
|
||
"insar_source_reason": insar_source_reason,
|
||
"file_path": radar_folder_path,
|
||
"has_orbit_data": has_orbit_data,
|
||
"orbit_file_path": orbit_file_path,
|
||
"is_envi_processed": is_envi_processed,
|
||
"coverage_polygon": coverage_polygon,
|
||
"geom": from_shape(poly, srid=4326),
|
||
"min_lon": min(lons),
|
||
"min_lat": min(lats),
|
||
"max_lon": max(lons),
|
||
"max_lat": max(lats),
|
||
}
|
||
|
||
stmt = pg_insert(RadarDataORM).values(data_to_upsert)
|
||
update_dict = {c.name: c for c in stmt.excluded if c.name != "unique_id"}
|
||
stmt = stmt.on_conflict_do_update(
|
||
index_elements=['unique_id'],
|
||
set_=update_dict,
|
||
)
|
||
await db.execute(stmt)
|
||
radar_cache_candidates[unique_id] = {
|
||
"unique_id": unique_id,
|
||
"file_path": radar_folder_path,
|
||
}
|
||
processed_scenes += 1
|
||
|
||
scan_state_updates.append(("radar", radar_dir_abs, max_mtime))
|
||
|
||
# 3. 更新缺失精轨的现有记录
|
||
update_progress("正在关联精轨数据...", 85)
|
||
updated_orbits = 0
|
||
updated_orbit_scene_ids = set()
|
||
if orbit_files_map:
|
||
stmt_select = select(RadarDataORM).where(RadarDataORM.has_orbit_data == False)
|
||
result = await db.execute(stmt_select)
|
||
records_to_update = result.scalars().all()
|
||
|
||
for record in records_to_update:
|
||
key = (record.satellite, record.imaging_date)
|
||
if key in orbit_files_map:
|
||
record.has_orbit_data = True
|
||
record.orbit_file_path = orbit_files_map[key]
|
||
db.add(record)
|
||
updated_orbits += 1
|
||
if record.id is not None:
|
||
updated_orbit_scene_ids.add(int(record.id))
|
||
|
||
for data_type, root_path, mtime in scan_state_updates:
|
||
await DataService._upsert_scan_state(db, data_type, root_path, mtime)
|
||
|
||
await db.commit()
|
||
|
||
pairing_dirty_summary: Dict[str, Any] = {}
|
||
dirty_scene_ids = set(updated_orbit_scene_ids)
|
||
processed_unique_ids = [key for key in radar_cache_candidates.keys() if key]
|
||
for chunk in _chunked(processed_unique_ids, 500):
|
||
id_result = await db.execute(
|
||
select(RadarDataORM.id).where(RadarDataORM.unique_id.in_(chunk))
|
||
)
|
||
dirty_scene_ids.update(int(value) for value in id_result.scalars().all() if value is not None)
|
||
|
||
if dirty_scene_ids:
|
||
pairing_dirty_summary = await pairing_state_service.mark_scenes_dirty(
|
||
db,
|
||
scene_ids=sorted(dirty_scene_ids),
|
||
reason="radar_scan",
|
||
commit=True,
|
||
)
|
||
elif processed_scenes > 0 or updated_orbits > 0:
|
||
pairing_dirty_summary = await pairing_state_service.mark_global_dirty(
|
||
db,
|
||
reason="radar_scan",
|
||
commit=True,
|
||
)
|
||
|
||
cached_previews = 0
|
||
skipped_previews = 0
|
||
missing_preview_sources = 0
|
||
failed_preview_cache = 0
|
||
cached_raw_previews = 0
|
||
skipped_raw_previews = 0
|
||
failed_raw_preview_cache = 0
|
||
|
||
radar_roots = [os.path.abspath(path) for path in radar_dirs if path]
|
||
new_candidate_keys = set(radar_cache_candidates.keys())
|
||
candidates: List[Dict[str, Any]] = []
|
||
if radar_roots:
|
||
existing_res = await db.execute(select(RadarDataORM))
|
||
for record in existing_res.scalars().all():
|
||
if not record.file_path:
|
||
continue
|
||
|
||
file_path_abs = os.path.abspath(record.file_path)
|
||
if not any(file_path_abs.startswith(root) for root in radar_roots):
|
||
continue
|
||
|
||
unique_id = record.unique_id or record.file_path
|
||
raw_cache_path = DataService.get_radar_raw_cache_path(unique_id, record.file_path)
|
||
geo_cache_path = DataService.get_radar_geo_cache_path(unique_id, record.file_path)
|
||
should_include = unique_id in new_candidate_keys
|
||
if not should_include:
|
||
if not os.path.exists(geo_cache_path):
|
||
should_include = True
|
||
elif (record.preview_cache_version or "") != settings.RADAR_GEO_CACHE_VERSION:
|
||
should_include = True
|
||
elif (record.preview_cache_status or "NONE") != "READY":
|
||
should_include = True
|
||
elif not os.path.exists(raw_cache_path):
|
||
should_include = True
|
||
|
||
if not should_include:
|
||
continue
|
||
|
||
candidates.append(
|
||
{
|
||
"id": record.id,
|
||
"unique_id": unique_id,
|
||
"file_path": record.file_path,
|
||
"coverage_polygon": record.coverage_polygon,
|
||
"min_lon": record.min_lon,
|
||
"min_lat": record.min_lat,
|
||
"max_lon": record.max_lon,
|
||
"max_lat": record.max_lat,
|
||
"preview_cache_version": record.preview_cache_version,
|
||
}
|
||
)
|
||
|
||
total_candidates = len(candidates)
|
||
if total_candidates > 0:
|
||
update_progress("正在生成源影像缓存...", 90)
|
||
workers = max(1, min(8, settings.RADAR_GEO_CACHE_WORKERS))
|
||
thumb_size = (settings.RADAR_THUMBNAIL_MAX_SIZE, settings.RADAR_THUMBNAIL_MAX_SIZE)
|
||
semaphore = asyncio.Semaphore(workers)
|
||
|
||
async def _cache_one(item: Dict[str, Any]) -> Dict[str, Any]:
|
||
async with semaphore:
|
||
item_id = int(item["id"])
|
||
unique_id = str(item.get("unique_id") or "")
|
||
file_path = str(item.get("file_path") or "")
|
||
raw_cache_path = DataService.get_radar_raw_cache_path(unique_id, file_path)
|
||
geo_cache_path = DataService.get_radar_geo_cache_path(unique_id, file_path)
|
||
|
||
preview_source = await asyncio.to_thread(DataService.find_radar_preview_source, file_path)
|
||
geo_exists = os.path.exists(geo_cache_path)
|
||
raw_exists = os.path.exists(raw_cache_path)
|
||
|
||
if not preview_source:
|
||
if geo_exists:
|
||
return {
|
||
"id": item_id,
|
||
"geo_status": "skipped",
|
||
"raw_status": "skipped" if raw_exists else "missing",
|
||
"db_values": {
|
||
"preview_cache_status": "READY",
|
||
"preview_cache_version": settings.RADAR_GEO_CACHE_VERSION,
|
||
"preview_cache_path": geo_cache_path,
|
||
"preview_cache_error": None,
|
||
},
|
||
}
|
||
return {
|
||
"id": item_id,
|
||
"geo_status": "missing_source",
|
||
"raw_status": "skipped" if raw_exists else "missing",
|
||
"db_values": {
|
||
"preview_cache_status": "NONE",
|
||
"preview_cache_version": settings.RADAR_GEO_CACHE_VERSION,
|
||
"preview_cache_path": None,
|
||
"preview_cache_error": "preview_source_not_found",
|
||
},
|
||
}
|
||
|
||
source_mtime = _safe_mtime(preview_source)
|
||
raw_cache_mtime = _safe_mtime(raw_cache_path)
|
||
geo_cache_mtime = _safe_mtime(geo_cache_path)
|
||
|
||
coverage_polygon = DataService._normalize_coverage_polygon(item.get("coverage_polygon"))
|
||
try:
|
||
bbox = (
|
||
float(item.get("min_lon")),
|
||
float(item.get("min_lat")),
|
||
float(item.get("max_lon")),
|
||
float(item.get("max_lat")),
|
||
)
|
||
except (TypeError, ValueError):
|
||
bbox = None
|
||
|
||
source_corner_mapping = await asyncio.to_thread(
|
||
DataService.get_radar_source_corner_mapping,
|
||
file_path,
|
||
)
|
||
|
||
need_geo_rebuild = (
|
||
(not geo_cache_mtime)
|
||
or (geo_cache_mtime < source_mtime)
|
||
or ((item.get("preview_cache_version") or "") != settings.RADAR_GEO_CACHE_VERSION)
|
||
)
|
||
if not need_geo_rebuild and os.path.exists(geo_cache_path):
|
||
geo_status = "skipped"
|
||
geo_error: Optional[str] = None
|
||
else:
|
||
if not coverage_polygon:
|
||
geo_status = "failed"
|
||
geo_error = "invalid_coverage_polygon"
|
||
elif not bbox:
|
||
geo_status = "failed"
|
||
geo_error = "invalid_bbox"
|
||
else:
|
||
ok_geo, geo_error = await asyncio.to_thread(
|
||
image_service.create_geocorrected_radar_cached_image,
|
||
preview_source,
|
||
geo_cache_path,
|
||
coverage_polygon,
|
||
bbox,
|
||
source_corner_mapping,
|
||
thumb_size,
|
||
settings.RADAR_GEO_CACHE_QUALITY,
|
||
)
|
||
geo_status = "cached" if ok_geo else "failed"
|
||
|
||
need_raw_rebuild = (not raw_cache_mtime) or (raw_cache_mtime < source_mtime)
|
||
if not need_raw_rebuild:
|
||
raw_status = "skipped"
|
||
else:
|
||
ok_raw = await asyncio.to_thread(
|
||
image_service.create_radar_cached_image,
|
||
preview_source,
|
||
raw_cache_path,
|
||
thumb_size,
|
||
)
|
||
raw_status = "cached" if ok_raw else "failed"
|
||
|
||
if geo_status in {"cached", "skipped"} and os.path.exists(geo_cache_path):
|
||
db_values = {
|
||
"preview_cache_status": "READY",
|
||
"preview_cache_version": settings.RADAR_GEO_CACHE_VERSION,
|
||
"preview_cache_path": geo_cache_path,
|
||
"preview_cache_updated_at": datetime.utcnow(),
|
||
"preview_cache_error": None,
|
||
}
|
||
elif geo_status == "failed":
|
||
db_values = {
|
||
"preview_cache_status": "FAILED",
|
||
"preview_cache_version": settings.RADAR_GEO_CACHE_VERSION,
|
||
"preview_cache_path": None,
|
||
"preview_cache_updated_at": datetime.utcnow(),
|
||
"preview_cache_error": geo_error or "geo_cache_build_failed",
|
||
}
|
||
else:
|
||
db_values = {
|
||
"preview_cache_status": "NONE",
|
||
"preview_cache_version": settings.RADAR_GEO_CACHE_VERSION,
|
||
"preview_cache_path": None,
|
||
"preview_cache_updated_at": datetime.utcnow(),
|
||
"preview_cache_error": "preview_source_not_found",
|
||
}
|
||
|
||
return {
|
||
"id": item_id,
|
||
"geo_status": geo_status,
|
||
"raw_status": raw_status,
|
||
"db_values": db_values,
|
||
}
|
||
|
||
processed = 0
|
||
for chunk in _chunked(candidates, workers):
|
||
results = await asyncio.gather(*[_cache_one(item) for item in chunk])
|
||
for result_item in results:
|
||
processed += 1
|
||
geo_status = result_item.get("geo_status")
|
||
raw_status = result_item.get("raw_status")
|
||
if geo_status == "cached":
|
||
cached_previews += 1
|
||
elif geo_status == "skipped":
|
||
skipped_previews += 1
|
||
elif geo_status == "missing_source":
|
||
missing_preview_sources += 1
|
||
else:
|
||
failed_preview_cache += 1
|
||
|
||
if raw_status == "cached":
|
||
cached_raw_previews += 1
|
||
elif raw_status == "skipped":
|
||
skipped_raw_previews += 1
|
||
elif raw_status == "failed":
|
||
failed_raw_preview_cache += 1
|
||
|
||
db_values = result_item.get("db_values")
|
||
if db_values:
|
||
await db.execute(
|
||
update(RadarDataORM)
|
||
.where(RadarDataORM.id == result_item["id"])
|
||
.values(**db_values)
|
||
)
|
||
|
||
prog = 90 + int((processed / total_candidates) * 10)
|
||
update_progress(
|
||
f"正在生成源影像缓存 ({processed}/{total_candidates})...",
|
||
min(100, prog),
|
||
)
|
||
|
||
await db.commit()
|
||
|
||
return {
|
||
"message": "数据扫描完成",
|
||
"processed_scenes": processed_scenes,
|
||
"total_orbit_files": len(orbit_files_map),
|
||
"updated_orbits": updated_orbits,
|
||
"pairing_dirty_scene_count": int(pairing_dirty_summary.get("dirty_scene_count") or 0),
|
||
"pairing_cache_status": pairing_dirty_summary.get("status"),
|
||
"cached_previews": cached_previews,
|
||
"skipped_previews": skipped_previews,
|
||
"missing_preview_sources": missing_preview_sources,
|
||
"failed_preview_cache": failed_preview_cache,
|
||
"cached_raw_previews": cached_raw_previews,
|
||
"skipped_raw_previews": skipped_raw_previews,
|
||
"failed_raw_preview_cache": failed_raw_preview_cache,
|
||
}
|
||
|
||
@staticmethod
|
||
async def scan_dinsar_results(
|
||
db: AsyncSession,
|
||
results_dirs: List[str],
|
||
task_id: Optional[str] = None,
|
||
progress_base: int = 0,
|
||
progress_span: int = 100
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
增量扫描 D-InSAR 结果目录,并为所有未缓存的结果创建图像缓存。
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
results_dirs: D-InSAR 结果目录列表
|
||
task_id: 任务 ID(用于进度更新)
|
||
|
||
Returns:
|
||
扫描结果字典
|
||
"""
|
||
def update_progress(msg: str, prog: int):
|
||
scaled = prog
|
||
if task_id:
|
||
scaled = progress_base + int((prog / 100) * progress_span)
|
||
scaled = min(progress_base + progress_span, max(progress_base, scaled))
|
||
asyncio.create_task(task_service.update_task(task_id, message=msg, progress=scaled))
|
||
print(f" [扫描进度] {scaled}% - {msg}")
|
||
|
||
os.makedirs(settings.DINSAR_CACHE_DIR, exist_ok=True)
|
||
MANIFEST_PATH = DataService.get_dinsar_manifest_path()
|
||
|
||
update_progress("正在检查数据库现有记录...", 5)
|
||
|
||
# 1. 从数据库获取现有记录(按 file_path 去重)
|
||
existing_rows_result = await db.execute(
|
||
select(DinsarResultORM.id, DinsarResultORM.file_path)
|
||
)
|
||
existing_rows = existing_rows_result.all()
|
||
|
||
path_map: Dict[str, int] = {}
|
||
duplicate_ids: List[int] = []
|
||
|
||
for record_id, file_path in existing_rows:
|
||
if not file_path:
|
||
continue
|
||
|
||
existing_id = path_map.get(file_path)
|
||
if existing_id is None:
|
||
path_map[file_path] = record_id
|
||
continue
|
||
if record_id > existing_id:
|
||
duplicate_ids.append(existing_id)
|
||
path_map[file_path] = record_id
|
||
else:
|
||
duplicate_ids.append(record_id)
|
||
|
||
if duplicate_ids:
|
||
await db.execute(
|
||
delete(DinsarResultORM).where(DinsarResultORM.id.in_(duplicate_ids))
|
||
)
|
||
await db.commit()
|
||
print(f"检测到重复记录,已清理 {len(duplicate_ids)} 条 (按 file_path 去重)。")
|
||
|
||
existing_paths = set(path_map.keys())
|
||
print(f"数据库中已存在 {len(existing_paths)} 条记录。开始增量文件扫描...")
|
||
|
||
new_files_metadata = []
|
||
updated_existing = 0
|
||
dinsar_state_updates = []
|
||
|
||
# 2. 统计总量并遍历文件系统收集新文件 (全量遍历,仅新增入库)
|
||
update_progress("正在统计 D-InSAR 结果总量...", 8)
|
||
total_candidates = 0
|
||
for results_dir in results_dirs:
|
||
if not os.path.exists(results_dir):
|
||
continue
|
||
total_candidates += sum(1 for _ in _iter_dinsar_result_candidates(results_dir))
|
||
|
||
if total_candidates == 0:
|
||
update_progress("未找到任何可识别的 D-InSAR 结果。", 10)
|
||
|
||
update_progress("正在遍历文件系统查找新结果...", 10)
|
||
processed_candidate_total = 0
|
||
scan_progress_base = 10
|
||
scan_progress_span = 15
|
||
for results_dir in results_dirs:
|
||
results_dir_abs = os.path.abspath(results_dir)
|
||
max_mtime = 0.0
|
||
if not os.path.exists(results_dir):
|
||
print(f"警告: Dinsar结果目录不存在,已跳过: {results_dir}")
|
||
continue
|
||
|
||
for candidate in _iter_dinsar_result_candidates(results_dir):
|
||
processed_candidate_total += 1
|
||
if processed_candidate_total % 50 == 0 or processed_candidate_total == total_candidates:
|
||
prog = scan_progress_base + int((processed_candidate_total / max(total_candidates, 1)) * scan_progress_span)
|
||
update_progress(
|
||
f"正在遍历文件系统查找新结果 ({processed_candidate_total}/{max(total_candidates, 1)})...",
|
||
min(scan_progress_base + scan_progress_span, prog),
|
||
)
|
||
|
||
scan_ts = candidate["scan_ts"]
|
||
if scan_ts > max_mtime:
|
||
max_mtime = scan_ts
|
||
base_name = candidate["name"]
|
||
data_file_path = candidate["file_path"]
|
||
|
||
if not os.path.exists(data_file_path):
|
||
continue
|
||
if data_file_path in existing_paths:
|
||
continue
|
||
|
||
update_progress(f"正在解析新文件元数据: {base_name}", 25)
|
||
|
||
try:
|
||
meta = image_service.extract_footprint(data_file_path)
|
||
poly = shape(meta["coverage_polygon"])
|
||
if not poly.is_valid:
|
||
poly = poly.buffer(0)
|
||
|
||
data_payload = {
|
||
"name": base_name,
|
||
"file_path": data_file_path,
|
||
"min_lon": meta["min_lon"],
|
||
"min_lat": meta["min_lat"],
|
||
"max_lon": meta["max_lon"],
|
||
"max_lat": meta["max_lat"],
|
||
"coverage_polygon": meta["coverage_polygon"],
|
||
"geom": from_shape(poly, srid=4326),
|
||
"is_cached": False,
|
||
}
|
||
|
||
new_files_metadata.append(data_payload)
|
||
existing_paths.add(data_file_path)
|
||
except Exception as e:
|
||
print(f"读取新文件元数据失败: {data_file_path}, 错误: {e}")
|
||
dinsar_state_updates.append(("dinsar", results_dir_abs, max_mtime))
|
||
|
||
# 3. 批量插入新文件
|
||
|
||
if new_files_metadata or updated_existing:
|
||
print(f"发现 {len(new_files_metadata)} 个新文件,更新 {updated_existing} 条已存在记录。")
|
||
try:
|
||
if new_files_metadata:
|
||
stmt = pg_insert(DinsarResultORM).values(new_files_metadata)
|
||
stmt = stmt.on_conflict_do_nothing(index_elements=['file_path'])
|
||
await db.execute(stmt)
|
||
await db.commit()
|
||
except Exception as e:
|
||
await db.rollback()
|
||
print(f"数据库批量写入新文件失败: {e}")
|
||
else:
|
||
print("未发现新的Dinsar结果文件。")
|
||
|
||
# 4. 检查需要处理的记录
|
||
update_progress("正在检查数据库一致性与缓存状态...", 25)
|
||
to_update_result = await db.execute(select(DinsarResultORM))
|
||
all_records = to_update_result.scalars().all()
|
||
|
||
# 5. 加载现有清单
|
||
manifest = {}
|
||
if os.path.exists(MANIFEST_PATH):
|
||
with open(MANIFEST_PATH, 'r', encoding='utf-8') as f:
|
||
try:
|
||
manifest = json.load(f)
|
||
except json.JSONDecodeError:
|
||
print("警告: 缓存清单文件损坏,将重新创建。")
|
||
|
||
records_to_process = []
|
||
cached_count = 0
|
||
updated_meta_count = 0
|
||
|
||
for record in all_records:
|
||
if not record.name:
|
||
continue
|
||
|
||
is_old_format = (
|
||
isinstance(record.coverage_polygon, list) or
|
||
(record.min_lon is None) or
|
||
(record.min_lon > 180) or
|
||
(record.coverage_polygon is None)
|
||
)
|
||
|
||
thumb_cache_filename = DataService.get_dinsar_cache_filename(record.id, record.name)
|
||
thumb_cache_path = DataService.get_dinsar_cache_path(record.id, record.name)
|
||
file_missing = not os.path.exists(thumb_cache_path)
|
||
|
||
force_recache = is_old_format or file_missing
|
||
needs_meta_update = force_recache or (record.coverage_polygon is None) or (record.min_lon is None) or (record.min_lon > 180)
|
||
needs_cache_gen = force_recache or (not record.is_cached and file_missing)
|
||
|
||
if not needs_meta_update and not needs_cache_gen:
|
||
if not record.is_cached and not file_missing:
|
||
await db.execute(
|
||
update(DinsarResultORM)
|
||
.where(DinsarResultORM.id == record.id)
|
||
.values(is_cached=True)
|
||
)
|
||
manifest[thumb_cache_filename] = record.file_path
|
||
cached_count += 1
|
||
continue
|
||
|
||
records_to_process.append({
|
||
"id": record.id,
|
||
"name": record.name,
|
||
"file_path": record.file_path,
|
||
"force_recache": force_recache,
|
||
"needs_meta_update": needs_meta_update,
|
||
"needs_cache_gen": needs_cache_gen,
|
||
"cache_filename": thumb_cache_filename,
|
||
"cache_path": thumb_cache_path,
|
||
})
|
||
|
||
if not records_to_process and not new_files_metadata and cached_count == 0:
|
||
for data_type, root_path, mtime in dinsar_state_updates:
|
||
await DataService._upsert_scan_state(db, data_type, root_path, mtime)
|
||
await db.commit()
|
||
return {
|
||
"message": "扫描完成,所有结果均已同步且已缓存。",
|
||
"new_files_found": 0,
|
||
"updated_existing": updated_existing,
|
||
"cached_now": cached_count
|
||
}
|
||
|
||
print(f"找到 {len(records_to_process)} 个需要处理(缓存或补全元数据)的记录...")
|
||
|
||
# 6. 处理所有待办记录 (CPU/IO in threads, DB updates sequential)
|
||
total_to_process = len(records_to_process)
|
||
processed_count = 0
|
||
worker_cap = max(1, min(settings.DINSAR_CACHE_WORKERS, os.cpu_count() or 2))
|
||
batch_size = max(1, worker_cap * 2)
|
||
thumb_size = (settings.DINSAR_THUMBNAIL_MAX_SIZE, settings.DINSAR_THUMBNAIL_MAX_SIZE)
|
||
|
||
def _cleanup_cache(cache_path: str, record_name: str):
|
||
if os.path.exists(cache_path):
|
||
try:
|
||
os.remove(cache_path)
|
||
except OSError:
|
||
pass
|
||
old_png = os.path.join(settings.DINSAR_CACHE_DIR, f"{record_name}.png")
|
||
if os.path.exists(old_png):
|
||
try:
|
||
os.remove(old_png)
|
||
except OSError:
|
||
pass
|
||
|
||
async def _process_record(info: Dict[str, Any]) -> Dict[str, Any]:
|
||
result = dict(info)
|
||
if info.get("needs_meta_update"):
|
||
try:
|
||
meta = await asyncio.to_thread(image_service.extract_footprint, info["file_path"])
|
||
result["meta"] = meta
|
||
except Exception as e:
|
||
result["meta_error"] = str(e)
|
||
|
||
if info.get("needs_cache_gen"):
|
||
if info.get("force_recache"):
|
||
await asyncio.to_thread(_cleanup_cache, info["cache_path"], info["name"])
|
||
try:
|
||
ok = await asyncio.to_thread(
|
||
image_service.create_cached_image,
|
||
info["file_path"],
|
||
info["cache_path"],
|
||
thumb_size
|
||
)
|
||
result["cache_ok"] = bool(ok)
|
||
except Exception as e:
|
||
result["cache_error"] = str(e)
|
||
return result
|
||
|
||
for chunk in _chunked(records_to_process, batch_size):
|
||
results = await asyncio.gather(*[_process_record(info) for info in chunk])
|
||
for result in results:
|
||
processed_count += 1
|
||
prog = 30 + int((processed_count / max(total_to_process, 1)) * 65)
|
||
update_progress(
|
||
f"正在处理缓存与自愈 ({processed_count}/{total_to_process}): {result.get('name', '')}",
|
||
prog
|
||
)
|
||
|
||
updates = {}
|
||
meta = result.get("meta")
|
||
if meta:
|
||
try:
|
||
poly = shape(meta["coverage_polygon"])
|
||
if not poly.is_valid:
|
||
poly = poly.buffer(0)
|
||
updates.update({
|
||
"coverage_polygon": meta["coverage_polygon"],
|
||
"geom": from_shape(poly, srid=4326),
|
||
"min_lon": meta["min_lon"],
|
||
"min_lat": meta["min_lat"],
|
||
"max_lon": meta["max_lon"],
|
||
"max_lat": meta["max_lat"],
|
||
})
|
||
updated_meta_count += 1
|
||
except Exception as e:
|
||
print(f"补全旧记录多边形失败 (ID: {result.get('id')}): {e}")
|
||
elif result.get("meta_error"):
|
||
print(f"读取元数据失败 (ID: {result.get('id')}): {result.get('meta_error')}")
|
||
|
||
if result.get("cache_ok"):
|
||
updates["is_cached"] = True
|
||
manifest[result["cache_filename"]] = result["file_path"]
|
||
cached_count += 1
|
||
elif result.get("needs_cache_gen") and result.get("cache_error"):
|
||
print(f"缓存生成失败 (ID: {result.get('id')}): {result.get('cache_error')}")
|
||
|
||
if updates:
|
||
await db.execute(
|
||
update(DinsarResultORM)
|
||
.where(DinsarResultORM.id == result["id"])
|
||
.values(**updates)
|
||
)
|
||
|
||
# 7. 提交并保存
|
||
for data_type, root_path, mtime in dinsar_state_updates:
|
||
await DataService._upsert_scan_state(db, data_type, root_path, mtime)
|
||
|
||
if cached_count > 0 or updated_meta_count > 0:
|
||
try:
|
||
await db.commit()
|
||
if cached_count > 0:
|
||
with open(MANIFEST_PATH, 'w', encoding='utf-8') as f:
|
||
json.dump(manifest, f, indent=4, ensure_ascii=False)
|
||
print(f"成功更新了 {cached_count} 条缓存记录和 {updated_meta_count} 条元数据。")
|
||
except Exception as e:
|
||
await db.rollback()
|
||
print(f"提交数据库或保存清单时出错: {e}")
|
||
|
||
else:
|
||
await db.commit()
|
||
return {
|
||
"message": "Dinsar结果扫描与自愈完成",
|
||
"new_files_found": len(new_files_metadata),
|
||
"updated_existing": updated_existing,
|
||
"cached_now": cached_count,
|
||
"meta_updated": updated_meta_count
|
||
}
|
||
|
||
@staticmethod
|
||
async def scan_hazard_points(
|
||
db: AsyncSession,
|
||
shp_path: str
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
扫描地质灾害点 Shapefile 并存入数据库。
|
||
|
||
Args:
|
||
db: 数据库会话
|
||
shp_path: Shapefile 路径
|
||
|
||
Returns:
|
||
扫描结果字典
|
||
"""
|
||
if not os.path.exists(shp_path):
|
||
raise FileNotFoundError(f"未找到地质灾害点文件: {shp_path}")
|
||
|
||
try:
|
||
gdf = gpd.read_file(shp_path, engine='pyogrio')
|
||
if gdf.crs and gdf.crs.to_epsg() != 4326:
|
||
gdf = gdf.to_crs(epsg=4326)
|
||
|
||
points_to_upsert = []
|
||
def pick_value(row_obj, candidates):
|
||
for key in candidates:
|
||
if key in row_obj and row_obj[key] not in (None, ""):
|
||
return row_obj[key]
|
||
return None
|
||
|
||
def to_float(value):
|
||
try:
|
||
if value is None or value == "":
|
||
return None
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
for _, row in gdf.iterrows():
|
||
tybh_value = pick_value(row, ["TYBH", "tybh"])
|
||
if tybh_value is None:
|
||
tybh_value = pick_value(row, ["统一编", "统一编号", "UNIFIED_ID"])
|
||
|
||
if tybh_value is None:
|
||
continue
|
||
|
||
hazard_type = pick_value(row, ["灾害类型", "灾害类", "ZHLX", "hazard_type", "TYPE"])
|
||
hazard_name = pick_value(row, ["灾害名", "ZHMC", "hazard_name", "NAME"])
|
||
city = pick_value(row, ["市", "CITY", "city"])
|
||
county = pick_value(row, ["县", "COUNTY", "county"])
|
||
township = pick_value(row, ["乡", "TOWNSHIP", "township", "乡镇"])
|
||
|
||
geom_x = getattr(row.geometry, "x", None)
|
||
geom_y = getattr(row.geometry, "y", None)
|
||
|
||
lon_value = to_float(geom_x)
|
||
lat_value = to_float(geom_y)
|
||
if lon_value is None:
|
||
lon_value = to_float(pick_value(row, ["经度", "LON", "longitude"]))
|
||
if lat_value is None:
|
||
lat_value = to_float(pick_value(row, ["纬度", "维度", "LAT", "latitude"]))
|
||
|
||
if lon_value is None or lat_value is None:
|
||
continue
|
||
|
||
point_data = {
|
||
"tybh": str(tybh_value).strip(),
|
||
"hazard_type": hazard_type,
|
||
"hazard_name": hazard_name,
|
||
"city": city,
|
||
"county": county,
|
||
"township": township,
|
||
"longitude": lon_value,
|
||
"latitude": lat_value,
|
||
}
|
||
points_to_upsert.append(point_data)
|
||
|
||
if points_to_upsert:
|
||
for p in points_to_upsert:
|
||
p['geom'] = from_shape(Point(p['longitude'], p['latitude']), srid=4326)
|
||
|
||
stmt = pg_insert(HazardPointORM).values(p)
|
||
update_dict = {c.name: c for c in stmt.excluded if c.name != "tybh"}
|
||
stmt = stmt.on_conflict_do_update(
|
||
index_elements=['tybh'],
|
||
set_=update_dict,
|
||
)
|
||
await db.execute(stmt)
|
||
|
||
await db.commit()
|
||
return {"message": "灾害点同步完成", "count": len(points_to_upsert)}
|
||
|
||
return {"message": "未发现有效的灾害点数据", "count": 0}
|
||
|
||
except Exception as e:
|
||
await db.rollback()
|
||
raise ValueError(f"处理灾害点 Shapefile 失败: {e}")
|
||
|
||
|
||
# 全局服务实例
|
||
data_service = DataService()
|