diff --git a/backend/app/dinsar_engines/isce2_engine.py b/backend/app/dinsar_engines/isce2_engine.py index dcc3029..4326bde 100644 --- a/backend/app/dinsar_engines/isce2_engine.py +++ b/backend/app/dinsar_engines/isce2_engine.py @@ -27,12 +27,15 @@ LT1_FIXED_WAVELENGTH = 0.23793052222222222 DEFAULT_TARGET_GRID_SIZE_M = 10 DEFAULT_BBOX_MARGIN = 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_MAX_SEC = 120.0 TARGET_GRID_SIZE_MIN_M = 5 TARGET_GRID_SIZE_MAX_M = 100 RERUN_MODE_UNFINISHED_ONLY = "unfinished_only" RESUME_STAGE_CHOICES = {"", "unwrap", "geocode", "export"} +REFERENCE_MODE_CHOICES = {"none", "coh_median"} def _read_env(name: str, default: str = "") -> str: @@ -311,6 +314,22 @@ class Isce2Engine(DinsarEngine): raise ValueError("相干性阈值必须在 0 到 1 之间。") 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: try: bbox_margin = float(normalized["bbox_margin"]) @@ -527,6 +546,8 @@ class Isce2Engine(DinsarEngine): target_grid_size_m: int, bbox: str, coh_threshold: Any, + reference_mode: str, + reference_coh_threshold: Any, bbox_margin: Any, wavelength: Any, orbit_margin_sec: Any, @@ -563,6 +584,8 @@ class Isce2Engine(DinsarEngine): "target_grid_size_m": int(target_grid_size_m), "bbox": str(bbox or "").strip(), "coh_threshold": coh_threshold, + "reference_mode": str(reference_mode or "").strip(), + "reference_coh_threshold": reference_coh_threshold, "bbox_margin": bbox_margin, "wavelength": wavelength, "orbit_margin_sec": orbit_margin_sec, @@ -673,10 +696,17 @@ class Isce2Engine(DinsarEngine): force = bool(extra.get("force")) target_grid_size_m = int(extra.get("target_grid_size_m", DEFAULT_TARGET_GRID_SIZE_M)) bbox = extra.get("bbox", "") - coh_threshold = extra.get("coh_threshold") - bbox_margin = extra.get("bbox_margin") + coh_threshold = extra.get("coh_threshold", DEFAULT_COH_THRESHOLD) + 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 - 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")) 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, bbox=bbox, coh_threshold=coh_threshold, + reference_mode=reference_mode, + reference_coh_threshold=reference_coh_threshold, bbox_margin=bbox_margin, wavelength=wavelength, orbit_margin_sec=orbit_margin_sec, @@ -896,6 +928,8 @@ class Isce2Engine(DinsarEngine): "target_grid_size_m": target_grid_size_m, "bbox": bbox, "coh_threshold": coh_threshold, + "reference_mode": reference_mode, + "reference_coh_threshold": reference_coh_threshold, "bbox_margin": bbox_margin, "wavelength": wavelength, "orbit_margin_sec": orbit_margin_sec, @@ -1016,6 +1050,10 @@ class Isce2Engine(DinsarEngine): "force": force, "timeout_seconds": timeout, "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, "orbit_margin_sec": orbit_margin_sec, "runtime_id": runtime.runtime_id, diff --git a/backend/app/isce2_pipeline/export_isce_geotiff.py b/backend/app/isce2_pipeline/export_isce_geotiff.py index ebf640a..74d656d 100644 --- a/backend/app/isce2_pipeline/export_isce_geotiff.py +++ b/backend/app/isce2_pipeline/export_isce_geotiff.py @@ -11,6 +11,9 @@ gdal.UseExceptions() DEFAULT_WAVELENGTH = 0.23793052222222222 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: @@ -46,10 +49,23 @@ def parse_args() -> argparse.Namespace: default=0.05, 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( "--include-disp-full", 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() @@ -77,12 +93,69 @@ def write_geotiff(array: np.ndarray, ref_ds: gdal.Dataset, out_path: Path, nodat 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( work_dir: Path, output_dir: Path, prefix: str, wavelength: float, coh_threshold: float, + reference_mode: str = DEFAULT_REFERENCE_MODE, + reference_coh_threshold: float = DEFAULT_REFERENCE_COH_THRESHOLD, include_disp_full: bool = False, nodata: float = DEFAULT_NODATA, ) -> dict[str, Path]: @@ -104,14 +177,25 @@ def export_products( coh_band = 2 if cor_ds.RasterCount >= 2 else 1 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() - mask = (coh < coh_threshold) | (amp == 0) - disp_m[mask] = nodata - disp_m_full[amp == 0] = nodata + mask = (~amp_valid) | (~np.isfinite(disp_m)) | (~np.isfinite(coh)) | (coh < coh_threshold) + disp_m_masked = disp_m.copy() + disp_m_masked[mask] = nodata + disp_m_full[(~amp_valid) | (~np.isfinite(disp_m_full))] = nodata coh_out = coh.copy() coh_out[~coh_valid] = nodata @@ -120,27 +204,46 @@ def export_products( out_disp = output_dir / f"{prefix}_disp.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) out_disp_full = None if include_disp_full: out_disp_full = output_dir / f"{prefix}_disp_full.tif" 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_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"Output prefix: {prefix}") 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"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: - 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: - 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: print(f"Coherence range: [{valid_coh.min():.4f}, {valid_coh.max():.4f}]") print(f"Wrote: {out_disp}") @@ -171,6 +274,8 @@ def main() -> int: prefix=prefix, wavelength=args.wavelength, coh_threshold=args.coh_threshold, + reference_mode=args.reference_mode, + reference_coh_threshold=args.reference_coh_threshold, include_disp_full=args.include_disp_full, ) return 0 diff --git a/backend/app/isce2_pipeline/run_lt1_dinsar_pipeline.py b/backend/app/isce2_pipeline/run_lt1_dinsar_pipeline.py index f737d47..2217428 100644 --- a/backend/app/isce2_pipeline/run_lt1_dinsar_pipeline.py +++ b/backend/app/isce2_pipeline/run_lt1_dinsar_pipeline.py @@ -13,7 +13,13 @@ import xml.etree.ElementTree as ET from dataclasses import dataclass 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 ( DEFAULT_WSL_DEM_CANDIDATES, ensure_lt1_orbit_xml, @@ -146,6 +152,18 @@ def parse_args() -> argparse.Namespace: default=0.05, 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( "--target-grid-size-m", type=int, @@ -199,6 +217,8 @@ def parse_args() -> argparse.Namespace: raise ValueError("--orbit-margin-sec must be between 60 and 120 seconds") if args.target_grid_size_m <= 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: raise ValueError("--force cannot be used together with --resume-from") return args @@ -914,6 +934,8 @@ def main() -> int: prefix=output_prefix, wavelength=args.wavelength, coh_threshold=args.coh_threshold, + reference_mode=args.reference_mode, + reference_coh_threshold=args.reference_coh_threshold, include_disp_full=args.include_disp_full, ) diff --git a/deploy/wsl/runners/isce2_runner.py b/deploy/wsl/runners/isce2_runner.py index a36c9e5..08dbba9 100644 --- a/deploy/wsl/runners/isce2_runner.py +++ b/deploy/wsl/runners/isce2_runner.py @@ -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, "--bbox", params.get("bbox")) _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, "--wavelength", params.get("wavelength")) _append_optional(argv, "--orbit-margin-sec", params.get("orbit_margin_sec")) diff --git a/docs/ISCE2_STABILIZATION_UPDATELOG_20260427.md b/docs/ISCE2_STABILIZATION_UPDATELOG_20260427.md index c36d1b4..29b0dec 100644 --- a/docs/ISCE2_STABILIZATION_UPDATELOG_20260427.md +++ b/docs/ISCE2_STABILIZATION_UPDATELOG_20260427.md @@ -67,3 +67,17 @@ These operational steps are intentionally not committed: - Point local `.env` `PYINT_PREPARED_DEM_PATH` to the same prepared `.wgs84` file. - 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.