Files
insar-management-system-v2/backend/app/isce2_pipeline/export_isce_geotiff.py
T
Harmon dace8b20f6 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
2026-04-27 10:22:44 +08:00

286 lines
9.7 KiB
Python

#!/usr/bin/env python3
from __future__ import annotations
import argparse
from pathlib import Path
import numpy as np
from osgeo import gdal
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:
parser = argparse.ArgumentParser(
description="Export ISCE2 geocoded displacement/coherence products to GeoTIFF."
)
parser.add_argument(
"work_dir",
type=Path,
help="ISCE2 work directory containing interferogram/*.geo outputs",
)
parser.add_argument(
"--output-dir",
type=Path,
default=None,
help="Output directory for GeoTIFF files. Default: work_dir",
)
parser.add_argument(
"--prefix",
type=str,
default=None,
help="Output filename prefix. Default: work_dir basename",
)
parser.add_argument(
"--wavelength",
type=float,
default=DEFAULT_WAVELENGTH,
help="Radar wavelength in meters",
)
parser.add_argument(
"--coh-threshold",
type=float,
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 coherence-unmasked displacement GeoTIFF for debugging",
)
return parser.parse_args()
def write_geotiff(array: np.ndarray, ref_ds: gdal.Dataset, out_path: Path, nodata: float) -> None:
out_path.parent.mkdir(parents=True, exist_ok=True)
if out_path.exists():
out_path.unlink()
driver = gdal.GetDriverByName("GTiff")
ds = driver.Create(
str(out_path),
ref_ds.RasterXSize,
ref_ds.RasterYSize,
1,
gdal.GDT_Float32,
options=["COMPRESS=LZW", "TILED=YES"],
)
ds.SetGeoTransform(ref_ds.GetGeoTransform())
ds.SetProjection(ref_ds.GetProjection())
band = ds.GetRasterBand(1)
band.SetNoDataValue(nodata)
band.WriteArray(array.astype(np.float32))
ds.FlushCache()
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]:
unw_path = work_dir / "interferogram" / "filt_topophase.unw.geo.vrt"
cor_path = work_dir / "interferogram" / "topophase.cor.geo.vrt"
if not unw_path.exists():
raise FileNotFoundError(f"Missing unwrapped product: {unw_path}")
if not cor_path.exists():
raise FileNotFoundError(f"Missing coherence product: {cor_path}")
unw_ds = gdal.Open(str(unw_path))
cor_ds = gdal.Open(str(cor_path))
if unw_ds is None or cor_ds is None:
raise RuntimeError("Failed to open ISCE2 geo products with GDAL.")
amp = unw_ds.GetRasterBand(1).ReadAsArray().astype(np.float32)
phase = unw_ds.GetRasterBand(2).ReadAsArray().astype(np.float32)
coh_band = 2 if cor_ds.RasterCount >= 2 else 1
coh = cor_ds.GetRasterBand(coh_band).ReadAsArray().astype(np.float32)
coh_valid = np.isfinite(coh) & (coh > 0)
amp_valid = np.isfinite(amp) & (amp != 0)
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 = (~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
output_dir.mkdir(parents=True, exist_ok=True)
out_disp = output_dir / f"{prefix}_disp.tif"
out_coh = output_dir / f"{prefix}_coh.tif"
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_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"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_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:
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:
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}")
if out_disp_full is not None:
print(f"Wrote: {out_disp_full}")
print(f"Wrote: {out_coh}")
unw_ds = None
cor_ds = None
outputs: dict[str, Path] = {
"disp": out_disp,
"coh": out_coh,
}
if out_disp_full is not None:
outputs["disp_full"] = out_disp_full
return outputs
def main() -> int:
args = parse_args()
work_dir = args.work_dir.resolve()
output_dir = args.output_dir.resolve() if args.output_dir else work_dir
prefix = args.prefix or work_dir.name
export_products(
work_dir=work_dir,
output_dir=output_dir,
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
if __name__ == "__main__":
raise SystemExit(main())