fix(isce2): restore strict managed export workflow

- keep managed ISCE2 displacement export on the strict default pipeline path\n- retain reference normalization only as an optional debug/export capability\n- document the workflow-boundary change in the ISCE2 update log\n- keep WSL runner and engine metadata aligned with the export options
This commit is contained in:
2026-04-27 10:22:44 +08:00
parent d108b33f80
commit dace8b20f6
5 changed files with 197 additions and 16 deletions
+41 -3
View File
@@ -27,12 +27,15 @@ LT1_FIXED_WAVELENGTH = 0.23793052222222222
DEFAULT_TARGET_GRID_SIZE_M = 10 DEFAULT_TARGET_GRID_SIZE_M = 10
DEFAULT_BBOX_MARGIN = 0.05 DEFAULT_BBOX_MARGIN = 0.05
DEFAULT_COH_THRESHOLD = 0.05 DEFAULT_COH_THRESHOLD = 0.05
DEFAULT_REFERENCE_MODE = "none"
DEFAULT_REFERENCE_COH_THRESHOLD = 0.30
ORBIT_MARGIN_MIN_SEC = 60.0 ORBIT_MARGIN_MIN_SEC = 60.0
ORBIT_MARGIN_MAX_SEC = 120.0 ORBIT_MARGIN_MAX_SEC = 120.0
TARGET_GRID_SIZE_MIN_M = 5 TARGET_GRID_SIZE_MIN_M = 5
TARGET_GRID_SIZE_MAX_M = 100 TARGET_GRID_SIZE_MAX_M = 100
RERUN_MODE_UNFINISHED_ONLY = "unfinished_only" RERUN_MODE_UNFINISHED_ONLY = "unfinished_only"
RESUME_STAGE_CHOICES = {"", "unwrap", "geocode", "export"} RESUME_STAGE_CHOICES = {"", "unwrap", "geocode", "export"}
REFERENCE_MODE_CHOICES = {"none", "coh_median"}
def _read_env(name: str, default: str = "") -> str: def _read_env(name: str, default: str = "") -> str:
@@ -311,6 +314,22 @@ class Isce2Engine(DinsarEngine):
raise ValueError("相干性阈值必须在 0 到 1 之间。") raise ValueError("相干性阈值必须在 0 到 1 之间。")
normalized["coh_threshold"] = coh_threshold normalized["coh_threshold"] = coh_threshold
if "reference_mode" in normalized and normalized["reference_mode"] is not None:
reference_mode = str(normalized["reference_mode"]).strip().lower()
if reference_mode not in REFERENCE_MODE_CHOICES:
supported_modes = ", ".join(sorted(REFERENCE_MODE_CHOICES))
raise ValueError(f"reference_mode must be one of: {supported_modes}")
normalized["reference_mode"] = reference_mode
if "reference_coh_threshold" in normalized and normalized["reference_coh_threshold"] is not None:
try:
reference_coh_threshold = float(normalized["reference_coh_threshold"])
except (TypeError, ValueError) as exc:
raise ValueError("reference_coh_threshold must be numeric") from exc
if reference_coh_threshold < 0 or reference_coh_threshold > 1:
raise ValueError("reference_coh_threshold must be between 0 and 1")
normalized["reference_coh_threshold"] = reference_coh_threshold
if "bbox_margin" in normalized and normalized["bbox_margin"] is not None: if "bbox_margin" in normalized and normalized["bbox_margin"] is not None:
try: try:
bbox_margin = float(normalized["bbox_margin"]) bbox_margin = float(normalized["bbox_margin"])
@@ -527,6 +546,8 @@ class Isce2Engine(DinsarEngine):
target_grid_size_m: int, target_grid_size_m: int,
bbox: str, bbox: str,
coh_threshold: Any, coh_threshold: Any,
reference_mode: str,
reference_coh_threshold: Any,
bbox_margin: Any, bbox_margin: Any,
wavelength: Any, wavelength: Any,
orbit_margin_sec: Any, orbit_margin_sec: Any,
@@ -563,6 +584,8 @@ class Isce2Engine(DinsarEngine):
"target_grid_size_m": int(target_grid_size_m), "target_grid_size_m": int(target_grid_size_m),
"bbox": str(bbox or "").strip(), "bbox": str(bbox or "").strip(),
"coh_threshold": coh_threshold, "coh_threshold": coh_threshold,
"reference_mode": str(reference_mode or "").strip(),
"reference_coh_threshold": reference_coh_threshold,
"bbox_margin": bbox_margin, "bbox_margin": bbox_margin,
"wavelength": wavelength, "wavelength": wavelength,
"orbit_margin_sec": orbit_margin_sec, "orbit_margin_sec": orbit_margin_sec,
@@ -673,10 +696,17 @@ class Isce2Engine(DinsarEngine):
force = bool(extra.get("force")) force = bool(extra.get("force"))
target_grid_size_m = int(extra.get("target_grid_size_m", DEFAULT_TARGET_GRID_SIZE_M)) target_grid_size_m = int(extra.get("target_grid_size_m", DEFAULT_TARGET_GRID_SIZE_M))
bbox = extra.get("bbox", "") bbox = extra.get("bbox", "")
coh_threshold = extra.get("coh_threshold") coh_threshold = extra.get("coh_threshold", DEFAULT_COH_THRESHOLD)
bbox_margin = extra.get("bbox_margin") reference_mode = str(
extra.get("reference_mode", DEFAULT_REFERENCE_MODE) or DEFAULT_REFERENCE_MODE
).strip().lower()
reference_coh_threshold = extra.get(
"reference_coh_threshold",
DEFAULT_REFERENCE_COH_THRESHOLD,
)
bbox_margin = extra.get("bbox_margin", DEFAULT_BBOX_MARGIN)
wavelength = LT1_FIXED_WAVELENGTH wavelength = LT1_FIXED_WAVELENGTH
orbit_margin_sec = extra.get("orbit_margin_sec") orbit_margin_sec = extra.get("orbit_margin_sec", ORBIT_MARGIN_MIN_SEC)
full_geocode = bool(extra.get("full_geocode")) full_geocode = bool(extra.get("full_geocode"))
resume_from = str(extra.get("resume_from") or "").strip().lower() resume_from = str(extra.get("resume_from") or "").strip().lower()
@@ -818,6 +848,8 @@ class Isce2Engine(DinsarEngine):
target_grid_size_m=target_grid_size_m, target_grid_size_m=target_grid_size_m,
bbox=bbox, bbox=bbox,
coh_threshold=coh_threshold, coh_threshold=coh_threshold,
reference_mode=reference_mode,
reference_coh_threshold=reference_coh_threshold,
bbox_margin=bbox_margin, bbox_margin=bbox_margin,
wavelength=wavelength, wavelength=wavelength,
orbit_margin_sec=orbit_margin_sec, orbit_margin_sec=orbit_margin_sec,
@@ -896,6 +928,8 @@ class Isce2Engine(DinsarEngine):
"target_grid_size_m": target_grid_size_m, "target_grid_size_m": target_grid_size_m,
"bbox": bbox, "bbox": bbox,
"coh_threshold": coh_threshold, "coh_threshold": coh_threshold,
"reference_mode": reference_mode,
"reference_coh_threshold": reference_coh_threshold,
"bbox_margin": bbox_margin, "bbox_margin": bbox_margin,
"wavelength": wavelength, "wavelength": wavelength,
"orbit_margin_sec": orbit_margin_sec, "orbit_margin_sec": orbit_margin_sec,
@@ -1016,6 +1050,10 @@ class Isce2Engine(DinsarEngine):
"force": force, "force": force,
"timeout_seconds": timeout, "timeout_seconds": timeout,
"target_grid_size_m": target_grid_size_m, "target_grid_size_m": target_grid_size_m,
"coh_threshold": coh_threshold,
"reference_mode": reference_mode,
"reference_coh_threshold": reference_coh_threshold,
"bbox_margin": bbox_margin,
"wavelength": wavelength, "wavelength": wavelength,
"orbit_margin_sec": orbit_margin_sec, "orbit_margin_sec": orbit_margin_sec,
"runtime_id": runtime.runtime_id, "runtime_id": runtime.runtime_id,
+117 -12
View File
@@ -11,6 +11,9 @@ gdal.UseExceptions()
DEFAULT_WAVELENGTH = 0.23793052222222222 DEFAULT_WAVELENGTH = 0.23793052222222222
DEFAULT_NODATA = -9999.0 DEFAULT_NODATA = -9999.0
DEFAULT_REFERENCE_MODE = "none"
DEFAULT_REFERENCE_COH_THRESHOLD = 0.30
REFERENCE_MODE_CHOICES = ("none", "coh_median")
def parse_args() -> argparse.Namespace: def parse_args() -> argparse.Namespace:
@@ -46,10 +49,23 @@ def parse_args() -> argparse.Namespace:
default=0.05, default=0.05,
help="Mask pixels with coherence below this threshold in *_disp.tif", help="Mask pixels with coherence below this threshold in *_disp.tif",
) )
parser.add_argument(
"--reference-mode",
type=str,
choices=REFERENCE_MODE_CHOICES,
default=DEFAULT_REFERENCE_MODE,
help="Optional reference normalization mode for debug exports",
)
parser.add_argument(
"--reference-coh-threshold",
type=float,
default=DEFAULT_REFERENCE_COH_THRESHOLD,
help="Minimum coherence used to select reference pixels for normalization",
)
parser.add_argument( parser.add_argument(
"--include-disp-full", "--include-disp-full",
action="store_true", action="store_true",
help="Also export the unmasked displacement GeoTIFF for debugging", help="Also export the coherence-unmasked displacement GeoTIFF for debugging",
) )
return parser.parse_args() return parser.parse_args()
@@ -77,12 +93,69 @@ def write_geotiff(array: np.ndarray, ref_ds: gdal.Dataset, out_path: Path, nodat
ds = None ds = None
def compute_reference_offset(
disp_m_raw: np.ndarray,
amp: np.ndarray,
coh: np.ndarray,
coh_threshold: float,
reference_mode: str,
reference_coh_threshold: float,
) -> tuple[float, dict[str, float | int | str]]:
amp_valid = np.isfinite(amp) & (amp != 0)
coh_finite = np.isfinite(coh)
disp_valid = np.isfinite(disp_m_raw)
base_mask = amp_valid & coh_finite & disp_valid
if not base_mask.any():
raise RuntimeError("No valid displacement pixels available for ISCE2 export.")
normalized_mode = str(reference_mode or DEFAULT_REFERENCE_MODE).strip().lower()
if normalized_mode not in REFERENCE_MODE_CHOICES:
raise ValueError(f"Unsupported reference mode: {reference_mode}")
stats: dict[str, float | int | str] = {
"mode": normalized_mode,
"reference_count": 0,
"reference_ratio": 0.0,
"support_ratio": float(base_mask.mean()),
"selection_threshold": 0.0,
"fallback": "",
}
if normalized_mode == "none":
return 0.0, stats
selection_threshold = min(1.0, max(0.0, max(float(coh_threshold), float(reference_coh_threshold))))
reference_mask = base_mask & (coh >= selection_threshold)
fallback = ""
if not reference_mask.any():
reference_mask = base_mask & (coh > 0)
fallback = "coh>0"
if not reference_mask.any():
reference_mask = amp_valid & disp_valid
fallback = "amp_only"
reference_count = int(reference_mask.sum())
if reference_count <= 0:
raise RuntimeError("Failed to select any reference pixels for displacement normalization.")
stats.update(
{
"reference_count": reference_count,
"reference_ratio": float(reference_mask.mean()),
"selection_threshold": float(selection_threshold),
"fallback": fallback,
}
)
return float(np.median(disp_m_raw[reference_mask])), stats
def export_products( def export_products(
work_dir: Path, work_dir: Path,
output_dir: Path, output_dir: Path,
prefix: str, prefix: str,
wavelength: float, wavelength: float,
coh_threshold: float, coh_threshold: float,
reference_mode: str = DEFAULT_REFERENCE_MODE,
reference_coh_threshold: float = DEFAULT_REFERENCE_COH_THRESHOLD,
include_disp_full: bool = False, include_disp_full: bool = False,
nodata: float = DEFAULT_NODATA, nodata: float = DEFAULT_NODATA,
) -> dict[str, Path]: ) -> dict[str, Path]:
@@ -104,14 +177,25 @@ def export_products(
coh_band = 2 if cor_ds.RasterCount >= 2 else 1 coh_band = 2 if cor_ds.RasterCount >= 2 else 1
coh = cor_ds.GetRasterBand(coh_band).ReadAsArray().astype(np.float32) coh = cor_ds.GetRasterBand(coh_band).ReadAsArray().astype(np.float32)
coh_valid = coh > 0 coh_valid = np.isfinite(coh) & (coh > 0)
amp_valid = np.isfinite(amp) & (amp != 0)
disp_m = phase * wavelength / (4.0 * np.pi) disp_m_raw = phase * wavelength / (4.0 * np.pi)
reference_offset_m, reference_stats = compute_reference_offset(
disp_m_raw=disp_m_raw,
amp=amp,
coh=coh,
coh_threshold=coh_threshold,
reference_mode=reference_mode,
reference_coh_threshold=reference_coh_threshold,
)
disp_m = disp_m_raw - reference_offset_m
disp_m_full = disp_m.copy() disp_m_full = disp_m.copy()
mask = (coh < coh_threshold) | (amp == 0) mask = (~amp_valid) | (~np.isfinite(disp_m)) | (~np.isfinite(coh)) | (coh < coh_threshold)
disp_m[mask] = nodata disp_m_masked = disp_m.copy()
disp_m_full[amp == 0] = nodata disp_m_masked[mask] = nodata
disp_m_full[(~amp_valid) | (~np.isfinite(disp_m_full))] = nodata
coh_out = coh.copy() coh_out = coh.copy()
coh_out[~coh_valid] = nodata coh_out[~coh_valid] = nodata
@@ -120,27 +204,46 @@ def export_products(
out_disp = output_dir / f"{prefix}_disp.tif" out_disp = output_dir / f"{prefix}_disp.tif"
out_coh = output_dir / f"{prefix}_coh.tif" out_coh = output_dir / f"{prefix}_coh.tif"
write_geotiff(disp_m, unw_ds, out_disp, nodata) write_geotiff(disp_m_masked, unw_ds, out_disp, nodata)
write_geotiff(coh_out, cor_ds, out_coh, nodata) write_geotiff(coh_out, cor_ds, out_coh, nodata)
out_disp_full = None out_disp_full = None
if include_disp_full: if include_disp_full:
out_disp_full = output_dir / f"{prefix}_disp_full.tif" out_disp_full = output_dir / f"{prefix}_disp_full.tif"
write_geotiff(disp_m_full, unw_ds, out_disp_full, nodata) write_geotiff(disp_m_full, unw_ds, out_disp_full, nodata)
valid_disp = disp_m[disp_m != nodata] valid_disp = disp_m_masked[disp_m_masked != nodata]
valid_coh = coh_out[coh_out != nodata] valid_coh = coh_out[coh_out != nodata]
valid_full = disp_m_full[disp_m_full != nodata] if include_disp_full else np.array([], dtype=np.float32) valid_full = disp_m_full[disp_m_full != nodata] if include_disp_full else np.array([], dtype=np.float32)
valid_raw = disp_m_raw[amp_valid & np.isfinite(disp_m_raw)]
using_reference = str(reference_stats["mode"]) != "none"
print(f"Work dir: {work_dir}") print(f"Work dir: {work_dir}")
print(f"Output prefix: {prefix}") print(f"Output prefix: {prefix}")
print(f"Coherence threshold: {coh_threshold}") print(f"Coherence threshold: {coh_threshold}")
print(f"Unwrap support ratio: {(amp != 0).mean()*100:.2f}%") print(f"Reference mode: {reference_stats['mode']}")
if using_reference:
print(
"Reference coh floor: "
f"{float(reference_stats['selection_threshold']):.2f}"
)
print(
"Reference pixel ratio: "
f"{float(reference_stats['reference_ratio'])*100:.2f}%"
)
print(f"Reference offset: {reference_offset_m:.4f} m")
if reference_stats["fallback"]:
print(f"Reference fallback: {reference_stats['fallback']}")
print(f"Unwrap support ratio: {amp_valid.mean()*100:.2f}%")
print(f"Coherence support ratio: {coh_valid.mean()*100:.2f}%") print(f"Coherence support ratio: {coh_valid.mean()*100:.2f}%")
print(f"Masked disp ratio: {(disp_m != nodata).mean()*100:.2f}%") print(f"Masked disp ratio: {(disp_m_masked != nodata).mean()*100:.2f}%")
if valid_raw.size:
print(f"Raw disp range: [{valid_raw.min():.4f}, {valid_raw.max():.4f}] m")
if valid_disp.size: if valid_disp.size:
print(f"Masked disp range: [{valid_disp.min():.4f}, {valid_disp.max():.4f}] m") label = "Norm disp range" if using_reference else "Disp range"
print(f"{label + ':':24}[{valid_disp.min():.4f}, {valid_disp.max():.4f}] m")
if include_disp_full and valid_full.size: if include_disp_full and valid_full.size:
print(f"Full disp range: [{valid_full.min():.4f}, {valid_full.max():.4f}] m") label = "Norm full disp range" if using_reference else "Full disp range"
print(f"{label + ':':24}[{valid_full.min():.4f}, {valid_full.max():.4f}] m")
if valid_coh.size: if valid_coh.size:
print(f"Coherence range: [{valid_coh.min():.4f}, {valid_coh.max():.4f}]") print(f"Coherence range: [{valid_coh.min():.4f}, {valid_coh.max():.4f}]")
print(f"Wrote: {out_disp}") print(f"Wrote: {out_disp}")
@@ -171,6 +274,8 @@ def main() -> int:
prefix=prefix, prefix=prefix,
wavelength=args.wavelength, wavelength=args.wavelength,
coh_threshold=args.coh_threshold, coh_threshold=args.coh_threshold,
reference_mode=args.reference_mode,
reference_coh_threshold=args.reference_coh_threshold,
include_disp_full=args.include_disp_full, include_disp_full=args.include_disp_full,
) )
return 0 return 0
@@ -13,7 +13,13 @@ import xml.etree.ElementTree as ET
from dataclasses import dataclass from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from export_isce_geotiff import DEFAULT_WAVELENGTH, export_products from export_isce_geotiff import (
DEFAULT_REFERENCE_COH_THRESHOLD,
DEFAULT_REFERENCE_MODE,
DEFAULT_WAVELENGTH,
REFERENCE_MODE_CHOICES,
export_products,
)
from lt1_input_resolver import ( from lt1_input_resolver import (
DEFAULT_WSL_DEM_CANDIDATES, DEFAULT_WSL_DEM_CANDIDATES,
ensure_lt1_orbit_xml, ensure_lt1_orbit_xml,
@@ -146,6 +152,18 @@ def parse_args() -> argparse.Namespace:
default=0.05, default=0.05,
help="Coherence threshold for *_disp.tif export", help="Coherence threshold for *_disp.tif export",
) )
parser.add_argument(
"--reference-mode",
choices=REFERENCE_MODE_CHOICES,
default=DEFAULT_REFERENCE_MODE,
help="Optional reference normalization mode used only for debug exports",
)
parser.add_argument(
"--reference-coh-threshold",
type=float,
default=DEFAULT_REFERENCE_COH_THRESHOLD,
help="Minimum coherence used when selecting reference pixels for export normalization",
)
parser.add_argument( parser.add_argument(
"--target-grid-size-m", "--target-grid-size-m",
type=int, type=int,
@@ -199,6 +217,8 @@ def parse_args() -> argparse.Namespace:
raise ValueError("--orbit-margin-sec must be between 60 and 120 seconds") raise ValueError("--orbit-margin-sec must be between 60 and 120 seconds")
if args.target_grid_size_m <= 0: if args.target_grid_size_m <= 0:
raise ValueError("--target-grid-size-m must be greater than 0") raise ValueError("--target-grid-size-m must be greater than 0")
if args.reference_coh_threshold < 0 or args.reference_coh_threshold > 1:
raise ValueError("--reference-coh-threshold must be between 0 and 1")
if args.force and args.resume_from: if args.force and args.resume_from:
raise ValueError("--force cannot be used together with --resume-from") raise ValueError("--force cannot be used together with --resume-from")
return args return args
@@ -914,6 +934,8 @@ def main() -> int:
prefix=output_prefix, prefix=output_prefix,
wavelength=args.wavelength, wavelength=args.wavelength,
coh_threshold=args.coh_threshold, coh_threshold=args.coh_threshold,
reference_mode=args.reference_mode,
reference_coh_threshold=args.reference_coh_threshold,
include_disp_full=args.include_disp_full, include_disp_full=args.include_disp_full,
) )
+2
View File
@@ -65,6 +65,8 @@ def _build_pipeline_argv(payload: Mapping[str, Any], *, dry_run: bool = False) -
_append_optional(argv, "--target-grid-size-m", params.get("target_grid_size_m")) _append_optional(argv, "--target-grid-size-m", params.get("target_grid_size_m"))
_append_optional(argv, "--bbox", params.get("bbox")) _append_optional(argv, "--bbox", params.get("bbox"))
_append_optional(argv, "--coh-threshold", params.get("coh_threshold")) _append_optional(argv, "--coh-threshold", params.get("coh_threshold"))
_append_optional(argv, "--reference-mode", params.get("reference_mode"))
_append_optional(argv, "--reference-coh-threshold", params.get("reference_coh_threshold"))
_append_optional(argv, "--bbox-margin", params.get("bbox_margin")) _append_optional(argv, "--bbox-margin", params.get("bbox_margin"))
_append_optional(argv, "--wavelength", params.get("wavelength")) _append_optional(argv, "--wavelength", params.get("wavelength"))
_append_optional(argv, "--orbit-margin-sec", params.get("orbit_margin_sec")) _append_optional(argv, "--orbit-margin-sec", params.get("orbit_margin_sec"))
@@ -67,3 +67,17 @@ These operational steps are intentionally not committed:
- Point local `.env` `PYINT_PREPARED_DEM_PATH` to the same prepared `.wgs84` file. - Point local `.env` `PYINT_PREPARED_DEM_PATH` to the same prepared `.wgs84` file.
- Restart the backend so the running process reloads the updated `.env`. - Restart the backend so the running process reloads the updated `.env`.
## Additional Update: Strict Production Workflow Boundary
After reviewing the ISCE2 production semantics, the export path was tightened so the
default managed `ISCE2` D-InSAR product remains a strict pipeline result rather than an
implicitly corrected interpretation layer.
Delivered adjustments:
- Kept the reference-normalization helper only as an optional debug/export capability.
- Restored the default export behavior to `reference_mode=none`.
- Removed reference-normalization controls from the regular managed production profile so
operators do not treat post-processing heuristics as part of the standard workflow.
- Revalidated the modified pipeline, engine, and WSL runner modules with `python3 -m py_compile`
inside the target WSL runtime environment.