chore: initialize insar management system v2

This commit is contained in:
2026-04-14 13:16:01 +08:00
commit ecc72ec9cd
361 changed files with 2142522 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Bundled ISCE2 production pipeline scripts."""
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Convert LT-1 text precise orbit files to the XML structure expected by ISCE2 LUTAN1."""
from __future__ import annotations
import argparse
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from datetime import datetime, timedelta
from pathlib import Path
from typing import Iterable, Optional
@dataclass
class StateVector:
time: datetime
x: float
y: float
z: float
vx: float
vy: float
vz: float
def parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Convert LT-1 GpsData .txt orbit files to ISCE2 LUTAN1 orbit XML."
)
parser.add_argument("input_txt", type=Path, help="Input LT-1 GpsData text file")
parser.add_argument("output_xml", type=Path, help="Output orbit XML file")
parser.add_argument(
"--annotation-xml",
type=Path,
default=None,
help="Optional LT-1 annotation/meta XML used to clip the orbit to scene time +/- margin",
)
parser.add_argument(
"--start",
type=str,
default=None,
help="Optional explicit UTC start time, e.g. 2025-01-12T09:13:24.000000",
)
parser.add_argument(
"--stop",
type=str,
default=None,
help="Optional explicit UTC stop time, e.g. 2025-01-12T09:13:32.000000",
)
parser.add_argument(
"--margin-sec",
type=float,
default=60.0,
help="Seconds to expand around annotation/start-stop window",
)
return parser.parse_args()
def parse_flexible_datetime(value: str) -> datetime:
value = value.strip().replace("Z", "")
try:
return datetime.fromisoformat(value)
except ValueError:
pass
# LT-1 annotation files may store single-digit hours like T9:13:24.042863.
match = re.match(r"^(\d{4}-\d{2}-\d{2}T)(\d{1})(:.*)$", value)
if match:
value = f"{match.group(1)}0{match.group(2)}{match.group(3)}"
for fmt in ("%Y-%m-%dT%H:%M:%S.%f", "%Y-%m-%dT%H:%M:%S"):
try:
return datetime.strptime(value, fmt)
except ValueError:
continue
raise ValueError(f"Unsupported datetime format: {value}")
def parse_annotation_window(annotation_xml: Path, margin_sec: float) -> tuple[datetime, datetime]:
root = ET.parse(annotation_xml).getroot()
start_text = find_text(root, "productInfo/sceneInfo/start/timeUTC")
stop_text = find_text(root, "productInfo/sceneInfo/stop/timeUTC")
start_time = parse_flexible_datetime(start_text) - timedelta(seconds=margin_sec)
stop_time = parse_flexible_datetime(stop_text) + timedelta(seconds=margin_sec)
return start_time, stop_time
def find_text(root: ET.Element, path: str) -> str:
node = root.find(path)
if node is None or node.text is None:
raise ValueError(f"Missing XML path: {path}")
return node.text.strip()
def parse_orbit_file(input_txt: Path) -> list[StateVector]:
vectors: list[StateVector] = []
with input_txt.open("r", encoding="utf-8") as handle:
for line in handle:
if not line.strip() or line.startswith("#"):
continue
parts = line.split()
if len(parts) < 12:
continue
sec_float = float(parts[5])
sec_int = int(sec_float)
microsecond = int(round((sec_float - sec_int) * 1_000_000))
timestamp = datetime(
int(parts[0]),
int(parts[1]),
int(parts[2]),
int(parts[3]),
int(parts[4]),
sec_int,
microsecond,
)
vectors.append(
StateVector(
time=timestamp,
x=float(parts[6]),
y=float(parts[7]),
z=float(parts[8]),
vx=float(parts[9]),
vy=float(parts[10]),
vz=float(parts[11]),
)
)
if not vectors:
raise ValueError(f"No orbit records parsed from {input_txt}")
return vectors
def clip_vectors(
vectors: Iterable[StateVector],
start_time: Optional[datetime],
stop_time: Optional[datetime],
) -> list[StateVector]:
if start_time is None and stop_time is None:
return list(vectors)
clipped = [
vector
for vector in vectors
if (start_time is None or vector.time >= start_time)
and (stop_time is None or vector.time <= stop_time)
]
if not clipped:
raise ValueError("No orbit records left after time clipping")
return clipped
def build_xml(vectors: Iterable[StateVector]) -> ET.ElementTree:
root = ET.Element("Earth_Explorer_File")
data_block = ET.SubElement(root, "Data_Block")
vectors = list(vectors)
list_of_osvs = ET.SubElement(data_block, "List_of_OSVs", count=str(len(vectors)))
for vector in vectors:
osv = ET.SubElement(list_of_osvs, "OSV")
ET.SubElement(osv, "UTC").text = vector.time.strftime("%Y-%m-%dT%H:%M:%S.%f")
ET.SubElement(osv, "X").text = format_float(vector.x)
ET.SubElement(osv, "Y").text = format_float(vector.y)
ET.SubElement(osv, "Z").text = format_float(vector.z)
ET.SubElement(osv, "VX").text = format_float(vector.vx)
ET.SubElement(osv, "VY").text = format_float(vector.vy)
ET.SubElement(osv, "VZ").text = format_float(vector.vz)
ET.indent(root, space=" ")
return ET.ElementTree(root)
def format_float(value: float) -> str:
return f"{value:.10f}".rstrip("0").rstrip(".")
def main() -> None:
args = parse_args()
vectors = parse_orbit_file(args.input_txt)
start_time: Optional[datetime] = None
stop_time: Optional[datetime] = None
if args.annotation_xml is not None:
start_time, stop_time = parse_annotation_window(args.annotation_xml, args.margin_sec)
if args.start:
start_time = parse_flexible_datetime(args.start)
if args.stop:
stop_time = parse_flexible_datetime(args.stop)
clipped = clip_vectors(vectors, start_time, stop_time)
tree = build_xml(clipped)
args.output_xml.parent.mkdir(parents=True, exist_ok=True)
tree.write(args.output_xml, encoding="utf-8", xml_declaration=True)
print(f"Input orbit: {args.input_txt}")
print(f"Output xml: {args.output_xml}")
print(f"Records read: {len(vectors)}")
print(f"Records kept: {len(clipped)}")
if start_time and stop_time:
print(
"Window: "
f"{start_time.strftime('%Y-%m-%dT%H:%M:%S.%f')} -> "
f"{stop_time.strftime('%Y-%m-%dT%H:%M:%S.%f')}"
)
if __name__ == "__main__":
main()
@@ -0,0 +1,180 @@
#!/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
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(
"--include-disp-full",
action="store_true",
help="Also export the 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 export_products(
work_dir: Path,
output_dir: Path,
prefix: str,
wavelength: float,
coh_threshold: float,
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 = coh > 0
disp_m = phase * wavelength / (4.0 * np.pi)
disp_m_full = disp_m.copy()
mask = (coh < coh_threshold) | (amp == 0)
disp_m[mask] = nodata
disp_m_full[amp == 0] = 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, 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_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)
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"Coherence support ratio: {coh_valid.mean()*100:.2f}%")
print(f"Masked disp ratio: {(disp_m != nodata).mean()*100:.2f}%")
if valid_disp.size:
print(f"Masked disp range: [{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")
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,
include_disp_full=args.include_disp_full,
)
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,253 @@
#!/usr/bin/env python3
from __future__ import annotations
import sys
from dataclasses import dataclass
from pathlib import Path
from typing import Callable, Iterable, Mapping, Optional
try:
from .convert_lt1_orbit_to_isce_xml import (
build_xml,
clip_vectors,
parse_annotation_window,
parse_orbit_file,
)
except ImportError:
SCRIPT_DIR = Path(__file__).resolve().parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
from convert_lt1_orbit_to_isce_xml import ( # type: ignore
build_xml,
clip_vectors,
parse_annotation_window,
parse_orbit_file,
)
PathTransform = Callable[[str | Path], Path]
DEFAULT_WINDOWS_DEM_CANDIDATES = (
r"D:\SRTM30m\SRTMDEM_RSP_SARscape.wgs84",
r"D:\SRTM30m\SRTMDEM_RSP_SARscape",
)
DEFAULT_WSL_DEM_CANDIDATES = (
"/mnt/d/SRTM30m/SRTMDEM_RSP_SARscape.wgs84",
"/mnt/d/SRTM30m/SRTMDEM_RSP_SARscape",
)
DEFAULT_WINDOWS_ORBIT_POOL_CANDIDATES = (r"D:\orbit_pools\isce2",)
@dataclass(frozen=True)
class OrbitXmlResolution:
path: Path
source: str
source_txt: Optional[Path] = None
def identity_path_transform(value: str | Path) -> Path:
return value if isinstance(value, Path) else Path(str(value))
def load_env_file(path: Path) -> dict[str, str]:
values: dict[str, str] = {}
if not path.exists():
return values
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
stripped = line.strip()
if not stripped or stripped.startswith("#") or "=" not in stripped:
continue
key, value = stripped.split("=", 1)
values[key.strip()] = value.strip()
return values
def parse_scene_window(meta_path: Path, margin_sec: float = 0.0) -> tuple:
return parse_annotation_window(meta_path, margin_sec)
def resolve_orbit_pool_path(
explicit_path: str | Path | None = None,
env_values: Mapping[str, str] | None = None,
extra_candidates: Iterable[str | Path] | None = None,
default_candidates: Iterable[str | Path] | None = None,
path_transform: PathTransform = identity_path_transform,
) -> Optional[Path]:
candidates: list[str | Path] = []
if explicit_path:
candidates.append(explicit_path)
if extra_candidates:
candidates.extend(extra_candidates)
if env_values:
for key in ("ORBIT_POOL_ISCE2", "ISCE2_ORBIT_DIR"):
value = env_values.get(key)
if value:
candidates.append(value)
if default_candidates:
candidates.extend(default_candidates)
return resolve_existing_directory(candidates, path_transform=path_transform)
def resolve_prepared_dem_path(
explicit_path: str | Path | None = None,
env_values: Mapping[str, str] | None = None,
extra_candidates: Iterable[str | Path] | None = None,
default_candidates: Iterable[str | Path] | None = None,
path_transform: PathTransform = identity_path_transform,
) -> Optional[Path]:
candidates: list[str | Path] = []
if explicit_path:
candidates.extend(_prepared_dem_variants(explicit_path))
if extra_candidates:
for candidate in extra_candidates:
candidates.extend(_prepared_dem_variants(candidate))
if env_values:
env_dem = env_values.get("ISCE2_DEM_PATH")
if env_dem:
candidates.extend(_prepared_dem_variants(env_dem))
env_base = env_values.get("IDL_DINSAR_DEM_BASE_FILE")
if env_base:
if str(env_base).lower().endswith(".wgs84"):
candidates.extend(_prepared_dem_variants(env_base))
else:
candidates.extend((f"{env_base}.wgs84", env_base))
if default_candidates:
for candidate in default_candidates:
candidates.extend(_prepared_dem_variants(candidate))
return resolve_existing_prepared_file(candidates, path_transform=path_transform)
def resolve_existing_directory(
candidates: Iterable[str | Path],
path_transform: PathTransform = identity_path_transform,
) -> Optional[Path]:
seen: set[str] = set()
for candidate in candidates:
if not candidate:
continue
path = path_transform(candidate)
key = str(path)
if key in seen:
continue
seen.add(key)
if path.exists() and path.is_dir():
return path
return None
def resolve_existing_prepared_file(
candidates: Iterable[str | Path],
path_transform: PathTransform = identity_path_transform,
) -> Optional[Path]:
seen: set[str] = set()
for candidate in candidates:
if not candidate:
continue
path = path_transform(candidate)
key = str(path)
if key in seen:
continue
seen.add(key)
if path.exists() and Path(str(path) + ".xml").exists():
return path
return None
def ensure_lt1_orbit_xml(
date_yyyymmdd: str,
satellite: str,
annotation_xml: Path,
orbit_root: Path,
orbit_output_dir: Path,
margin_sec: float,
) -> OrbitXmlResolution:
stem = build_lt1_orbit_stem(satellite=satellite, date_yyyymmdd=date_yyyymmdd)
existing_xml = find_existing_lt1_orbit_xml(
date_yyyymmdd=date_yyyymmdd,
satellite=satellite,
orbit_root=orbit_root,
orbit_output_dir=orbit_output_dir,
)
if existing_xml is not None:
return OrbitXmlResolution(path=existing_xml, source="existing_xml")
txt_path = find_existing_lt1_orbit_text(
date_yyyymmdd=date_yyyymmdd,
satellite=satellite,
orbit_root=orbit_root,
)
if txt_path is None:
raise FileNotFoundError(
"Missing LT-1 precise orbit source. "
f"Searched under: {orbit_root} for {stem}.xml/.txt"
)
orbit_output_dir.mkdir(parents=True, exist_ok=True)
xml_path = orbit_output_dir / f"{stem}.xml"
vectors = parse_orbit_file(txt_path)
start_time, stop_time = parse_annotation_window(annotation_xml, margin_sec)
clipped = clip_vectors(vectors, start_time, stop_time)
tree = build_xml(clipped)
tree.write(xml_path, encoding="utf-8", xml_declaration=True)
return OrbitXmlResolution(path=xml_path, source="generated_from_txt", source_txt=txt_path)
def find_existing_lt1_orbit_xml(
date_yyyymmdd: str,
satellite: str,
orbit_root: Path,
orbit_output_dir: Path | None = None,
) -> Optional[Path]:
stem = build_lt1_orbit_stem(satellite=satellite, date_yyyymmdd=date_yyyymmdd)
candidates = []
if orbit_output_dir is not None:
candidates.append(orbit_output_dir / f"{stem}.xml")
candidates.extend(
[
orbit_root / f"{stem}.xml",
orbit_root / satellite.upper() / f"{stem}.xml",
orbit_root / "converted" / "isce2" / f"{stem}.xml",
]
)
for candidate in candidates:
if candidate.exists():
return candidate
return None
def find_existing_lt1_orbit_text(
date_yyyymmdd: str,
satellite: str,
orbit_root: Path,
) -> Optional[Path]:
stem = build_lt1_orbit_stem(satellite=satellite, date_yyyymmdd=date_yyyymmdd)
candidates = [
orbit_root / satellite.upper() / f"{stem}.txt",
orbit_root / f"{stem}.txt",
]
for candidate in candidates:
if candidate.exists():
return candidate
return None
def build_lt1_orbit_stem(satellite: str, date_yyyymmdd: str) -> str:
return f"{satellite.upper()}_GpsData_GAS_C_{date_yyyymmdd}"
def _prepared_dem_variants(value: str | Path) -> tuple[str | Path, ...]:
text = str(value).strip()
if not text:
return ()
if text.lower().endswith(".wgs84"):
return (value,)
return (value, f"{text}.wgs84")
@@ -0,0 +1,582 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import ast
import re
import shutil
import subprocess
import sys
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
from export_isce_geotiff import DEFAULT_WAVELENGTH, export_products
from lt1_input_resolver import (
DEFAULT_WSL_DEM_CANDIDATES,
ensure_lt1_orbit_xml,
resolve_prepared_dem_path,
)
DEFAULT_TARGET_GRID_SIZE_M = 10
METERS_PER_DEGREE = 111320.0
@dataclass
class Scene:
role: str
tiff_path: Path
meta_path: Path
date_yyyymmdd: str
orbit_xml_path: Path
@dataclass
class PipelineConfig:
task_name: str
output_prefix: str
dem_path: Path
reference: Scene
secondary: Scene
bbox: list[float] | None
target_grid_size_m: int
geo_posting_deg: float
def parse_args() -> argparse.Namespace:
script_dir = Path(__file__).resolve().parent
repo_root = script_dir.parent
parser = argparse.ArgumentParser(
description="Run an LT-1 ISCE2 DInSAR production pipeline with SNAPHU."
)
parser.add_argument("task_dir", help="Task directory, for example Task_20250112_20250309")
parser.add_argument(
"--task-name",
default=None,
help="Override the task name used for work directory and default outputs",
)
parser.add_argument(
"--work-root",
default=str(script_dir / "jobs"),
help="Root directory for ISCE2 work folders",
)
parser.add_argument(
"--work-dir",
default=None,
help="Explicit work directory. Overrides --work-root/<task_name>",
)
parser.add_argument(
"--output-dir",
default=None,
help="Directory for final GeoTIFFs. Default: work_dir",
)
parser.add_argument(
"--output-prefix",
default=None,
help="Prefix for final output filenames. Default: task name",
)
parser.add_argument(
"--orbit-root",
default=str(repo_root / "orbit"),
help="Directory containing LT1A_GpsData_GAS_C_YYYYMMDD.txt",
)
parser.add_argument(
"--orbit-output-dir",
default=None,
help="Directory to place generated orbit XML files. Default: work_dir/orbits",
)
parser.add_argument(
"--dem",
default=None,
help="DEM base path. Default: auto-detect the prepared WGS84 DEM",
)
parser.add_argument(
"--bbox",
default=None,
help="Optional geocode bounding box: south,north,west,east",
)
parser.add_argument(
"--bbox-margin",
type=float,
default=0.05,
help="Auto-expand topo estimated bbox by this many degrees on each side",
)
parser.add_argument(
"--orbit-margin-sec",
type=float,
default=60.0,
help="Seconds to expand around scene time when clipping precise orbit, must be between 60 and 120",
)
parser.add_argument(
"--master-dir-name",
default="master",
help="Subdirectory name for the reference scene inside the task directory",
)
parser.add_argument(
"--slave-dir-name",
default="slave",
help="Subdirectory name for the secondary scene inside the task directory",
)
parser.add_argument(
"--scene-glob",
default="*.tiff",
help="Glob pattern used to find scene files inside master/slave directories",
)
parser.add_argument(
"--prefer-scene-keyword",
default="_SLC_",
help="Prefer matching files containing this keyword when multiple scene files are present",
)
parser.add_argument(
"--coh-threshold",
type=float,
default=0.05,
help="Coherence threshold for *_disp.tif export",
)
parser.add_argument(
"--target-grid-size-m",
type=int,
default=DEFAULT_TARGET_GRID_SIZE_M,
help="Target grid size in meters used to control multilook scale and geocoding spacing",
)
parser.add_argument(
"--include-disp-full",
action="store_true",
help="Also export the unmasked displacement GeoTIFF for debugging",
)
parser.add_argument(
"--wavelength",
type=float,
default=DEFAULT_WAVELENGTH,
help="Radar wavelength in meters",
)
parser.add_argument(
"--force",
action="store_true",
help="Delete an existing work directory before rerunning",
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Resolve inputs and print the planned configuration without running ISCE2",
)
args = parser.parse_args()
if args.orbit_margin_sec < 60 or args.orbit_margin_sec > 120:
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")
return args
def locate_stripmap_app() -> Path:
import isce
app_path = Path(isce.__file__).resolve().parent / "applications" / "stripmapApp.py"
if not app_path.exists():
raise FileNotFoundError(f"stripmapApp.py not found: {app_path}")
return app_path
def normalize_linux_path(value: str | Path) -> Path:
text = str(value).strip()
if text.startswith("\\\\"):
raise ValueError("UNC paths are not supported directly. Mount them in WSL first.")
match = re.match(r"^([A-Za-z]):[\\/](.*)$", text)
if match:
drive = match.group(1).lower()
rest = match.group(2).replace("\\", "/")
return Path(f"/mnt/{drive}/{rest}")
return Path(text)
def choose_scene_tiff(scene_dir: Path, scene_glob: str, prefer_scene_keyword: str) -> Path:
candidates = sorted(scene_dir.glob(scene_glob))
if not candidates:
raise FileNotFoundError(f"No scene file matching {scene_glob} found in {scene_dir}")
slc_candidates = [path for path in candidates if prefer_scene_keyword in path.name]
if len(slc_candidates) == 1:
return slc_candidates[0]
if len(candidates) == 1:
return candidates[0]
raise RuntimeError(
f"Expected one scene file in {scene_dir}; found {len(candidates)} matches for {scene_glob}"
)
def scene_meta_from_tiff(tiff_path: Path) -> Path:
meta_path = Path(str(tiff_path).replace(".tiff", ".meta.xml"))
if not meta_path.exists():
raise FileNotFoundError(f"Missing meta XML for {tiff_path}: {meta_path}")
return meta_path
def extract_scene_date(name: str) -> str:
match = re.search(r"_(\d{8})_SLC_", name)
if not match:
match = re.search(r"(\d{8})", name)
if not match:
raise ValueError(f"Unable to extract scene date from filename: {name}")
return match.group(1)
def ensure_orbit_xml(
date_yyyymmdd: str,
annotation_xml: Path,
orbit_root: Path,
orbit_out_dir: Path,
margin_sec: float,
) -> Path:
resolution = ensure_lt1_orbit_xml(
date_yyyymmdd=date_yyyymmdd,
satellite="LT1A",
annotation_xml=annotation_xml,
orbit_root=orbit_root,
orbit_output_dir=orbit_out_dir,
margin_sec=margin_sec,
)
return resolution.path
def resolve_dem(dem_value: str | None) -> Path:
dem_path = resolve_prepared_dem_path(
explicit_path=dem_value,
env_values=None,
default_candidates=DEFAULT_WSL_DEM_CANDIDATES,
path_transform=normalize_linux_path,
)
if dem_path is not None:
return dem_path
searched = ", ".join(str(path) for path in DEFAULT_WSL_DEM_CANDIDATES)
raise FileNotFoundError(
"Unable to resolve a prepared DEM with ISCE wrappers. "
f"Searched: {searched}"
)
def resolve_task(
task_dir: Path,
orbit_root: Path,
orbit_out_dir: Path,
margin_sec: float,
master_dir_name: str,
slave_dir_name: str,
scene_glob: str,
prefer_scene_keyword: str,
) -> tuple[Scene, Scene]:
scenes: list[Scene] = []
for role, subdir in (("master", master_dir_name), ("slave", slave_dir_name)):
scene_dir = task_dir / subdir
if not scene_dir.exists():
raise FileNotFoundError(f"Missing task subdirectory: {scene_dir}")
tiff_path = choose_scene_tiff(scene_dir, scene_glob, prefer_scene_keyword)
meta_path = scene_meta_from_tiff(tiff_path)
date_yyyymmdd = extract_scene_date(tiff_path.name)
orbit_xml_path = ensure_orbit_xml(
date_yyyymmdd=date_yyyymmdd,
annotation_xml=meta_path,
orbit_root=orbit_root,
orbit_out_dir=orbit_out_dir,
margin_sec=margin_sec,
)
scenes.append(
Scene(
role=role,
tiff_path=tiff_path,
meta_path=meta_path,
date_yyyymmdd=date_yyyymmdd,
orbit_xml_path=orbit_xml_path,
)
)
return scenes[0], scenes[1]
def render_bbox(bbox: list[float] | None) -> str:
if bbox is None:
return ""
values = ", ".join(f"{value:.10f}".rstrip("0").rstrip(".") for value in bbox)
return f' <property name="geocode bounding box">[{values}]</property>\n'
def meters_to_geoposting_degrees(target_grid_size_m: int) -> float:
return float(target_grid_size_m) / METERS_PER_DEGREE
def write_stripmap_xml(xml_path: Path, config: PipelineConfig) -> None:
bbox_xml = render_bbox(config.bbox)
text = (
"<stripmapApp>\n"
" <component name=\"stripmapApp\">\n"
" <property name=\"sensor name\">LUTAN1</property>\n"
" <property name=\"reference sensor name\">LUTAN1</property>\n"
" <property name=\"secondary sensor name\">LUTAN1</property>\n"
" <property name=\"renderer\">xml</property>\n"
" <property name=\"do unwrap\">True</property>\n"
" <property name=\"unwrapper name\">snaphu</property>\n"
f" <property name=\"posting\">{config.target_grid_size_m}</property>\n"
f" <property name=\"geoPosting\">{config.geo_posting_deg:.12f}</property>\n"
f"{bbox_xml}"
f" <property name=\"demFilename\">{config.dem_path.as_posix()}</property>\n"
"\n"
" <component name=\"Reference\">\n"
f" <property name=\"tiff\">{config.reference.tiff_path.as_posix()}</property>\n"
f" <property name=\"orbitFile\">{config.reference.orbit_xml_path.as_posix()}</property>\n"
" <property name=\"OUTPUT\">reference</property>\n"
" </component>\n"
"\n"
" <component name=\"Secondary\">\n"
f" <property name=\"tiff\">{config.secondary.tiff_path.as_posix()}</property>\n"
f" <property name=\"orbitFile\">{config.secondary.orbit_xml_path.as_posix()}</property>\n"
" <property name=\"OUTPUT\">secondary</property>\n"
" </component>\n"
" </component>\n"
"</stripmapApp>\n"
)
xml_path.write_text(text, encoding="utf-8")
def run_logged(cmd: list[str], cwd: Path, log_path: Path) -> None:
log_path.parent.mkdir(parents=True, exist_ok=True)
print("Running:")
print(" " + " ".join(cmd))
print(f"Log: {log_path}")
with log_path.open("w", encoding="utf-8") as handle:
proc = subprocess.Popen(
cmd,
cwd=cwd,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
)
assert proc.stdout is not None
for line in proc.stdout:
sys.stdout.write(line)
handle.write(line)
status = proc.wait()
if status != 0:
raise RuntimeError(f"Command failed with exit code {status}: {' '.join(cmd)}")
def parse_bbox_arg(value: str | None) -> list[float] | None:
if value is None:
return None
parts = [item.strip() for item in value.split(",")]
if len(parts) != 4:
raise ValueError("Bounding box must be south,north,west,east")
return [float(item) for item in parts]
def load_estimated_bbox(topo_xml: Path) -> list[float]:
root = ET.fromstring(topo_xml.read_text(encoding="utf-8"))
for prop in root.findall("property"):
if prop.attrib.get("name") == "estimatedboundingbox":
value = prop.findtext("value")
if not value:
break
bbox = ast.literal_eval(value)
return [float(item) for item in bbox]
raise ValueError(f"estimatedboundingbox not found in {topo_xml}")
def expand_bbox(bbox: list[float], margin: float) -> list[float]:
south, north, west, east = bbox
return [
max(-90.0, south - margin),
min(90.0, north + margin),
max(-180.0, west - margin),
min(180.0, east + margin),
]
def prepare_snaphu_resume(work_dir: Path, bbox: list[float] | None) -> None:
pickle_dir = work_dir / "PICKLE"
src = pickle_dir / "filter"
src_xml = pickle_dir / "filter.xml"
dst = pickle_dir / "filter_high_band"
dst_xml = pickle_dir / "filter_high_band.xml"
if not src.exists() or not src_xml.exists():
raise FileNotFoundError("filter step output is missing; cannot prepare SNAPHU resume state.")
shutil.copy2(src, dst)
shutil.copy2(src_xml, dst_xml)
root = ET.fromstring(dst_xml.read_text(encoding="utf-8"))
props = {prop.attrib.get("name"): prop for prop in root.findall("property")}
required = {
"referenceslccroppedproduct": "reference_slc.xml",
"secondaryslccroppedproduct": "secondary_slc.xml",
"referenceslcproduct": "reference_slc.xml",
"secondaryslcproduct": "secondary_slc.xml",
"referencegeometrysystem": "Zero Doppler",
"secondarygeometrysystem": "Zero Doppler",
}
if bbox is not None:
required["estimatedboundingbox"] = str(bbox)
for name, value in required.items():
if name in props:
node = props[name].find("value")
if node is None:
node = ET.SubElement(props[name], "value")
node.text = value
continue
prop = ET.SubElement(root, "property", {"name": name})
ET.SubElement(prop, "value").text = value
dst_xml.write_text(ET.tostring(root, encoding="unicode"), encoding="utf-8")
def prepare_geocode_resume(work_dir: Path) -> None:
pickle_dir = work_dir / "PICKLE"
unwrap = pickle_dir / "unwrap"
unwrap_xml = pickle_dir / "unwrap.xml"
ionosphere = pickle_dir / "ionosphere"
ionosphere_xml = pickle_dir / "ionosphere.xml"
if not unwrap.exists() or not unwrap_xml.exists():
raise FileNotFoundError("unwrap step output is missing; cannot prepare geocode resume state.")
shutil.copy2(unwrap, ionosphere)
shutil.copy2(unwrap_xml, ionosphere_xml)
def print_summary(
task_dir: Path,
work_dir: Path,
output_dir: Path,
config: PipelineConfig,
) -> None:
print(f"Task dir: {task_dir}")
print(f"Task name: {config.task_name}")
print(f"Output prefix:{config.output_prefix}")
print(f"Work dir: {work_dir}")
print(f"Output dir: {output_dir}")
print(f"DEM: {config.dem_path}")
print(f"Reference: {config.reference.tiff_path}")
print(f"Secondary: {config.secondary.tiff_path}")
print(f"Ref orbit: {config.reference.orbit_xml_path}")
print(f"Sec orbit: {config.secondary.orbit_xml_path}")
print(f"BBox: {config.bbox if config.bbox is not None else 'auto'}")
print(f"Target grid: {config.target_grid_size_m} m")
print(f"Geo posting: {config.geo_posting_deg:.12f} deg")
def main() -> int:
args = parse_args()
task_dir = normalize_linux_path(args.task_dir).resolve()
if not task_dir.exists():
raise FileNotFoundError(f"Task directory not found: {task_dir}")
task_name = args.task_name or task_dir.name
output_prefix = args.output_prefix or task_name
work_root = normalize_linux_path(args.work_root).resolve()
work_dir = normalize_linux_path(args.work_dir).resolve() if args.work_dir else work_root / task_name
output_dir = normalize_linux_path(args.output_dir).resolve() if args.output_dir else work_dir
orbit_root = normalize_linux_path(args.orbit_root).resolve()
orbit_out_dir = (
normalize_linux_path(args.orbit_output_dir).resolve()
if args.orbit_output_dir
else work_dir / "orbits"
)
if work_dir.exists():
if not args.force:
raise FileExistsError(f"Work directory already exists: {work_dir}. Use --force to recreate it.")
shutil.rmtree(work_dir)
work_dir.mkdir(parents=True, exist_ok=True)
reference, secondary = resolve_task(
task_dir=task_dir,
orbit_root=orbit_root,
orbit_out_dir=orbit_out_dir,
margin_sec=args.orbit_margin_sec,
master_dir_name=args.master_dir_name,
slave_dir_name=args.slave_dir_name,
scene_glob=args.scene_glob,
prefer_scene_keyword=args.prefer_scene_keyword,
)
dem_path = resolve_dem(args.dem)
bbox = parse_bbox_arg(args.bbox)
geo_posting_deg = meters_to_geoposting_degrees(args.target_grid_size_m)
config = PipelineConfig(
task_name=task_name,
output_prefix=output_prefix,
dem_path=dem_path,
reference=reference,
secondary=secondary,
bbox=bbox,
target_grid_size_m=args.target_grid_size_m,
geo_posting_deg=geo_posting_deg,
)
print_summary(task_dir=task_dir, work_dir=work_dir, output_dir=output_dir, config=config)
xml_path = work_dir / f"{task_name}_stripmap.xml"
write_stripmap_xml(xml_path, config)
if args.dry_run:
print(f"Generated XML: {xml_path}")
return 0
app_py = locate_stripmap_app()
run_logged(
[sys.executable, app_py.as_posix(), xml_path.as_posix(), "--steps", "--end=filter"],
cwd=work_dir,
log_path=work_dir / "01_to_filter.log",
)
if config.bbox is None:
estimated_bbox = load_estimated_bbox(work_dir / "PICKLE" / "topo.xml")
config.bbox = expand_bbox(estimated_bbox, args.bbox_margin)
write_stripmap_xml(xml_path, config)
print(f"Auto bbox from topo: {estimated_bbox}")
print(f"Expanded bbox used for geocode: {config.bbox}")
prepare_snaphu_resume(work_dir, config.bbox)
run_logged(
[sys.executable, app_py.as_posix(), xml_path.as_posix(), "--steps", "--start=unwrap", "--end=unwrap"],
cwd=work_dir,
log_path=work_dir / "02_unwrap_snaphu.log",
)
prepare_geocode_resume(work_dir)
run_logged(
[sys.executable, app_py.as_posix(), xml_path.as_posix(), "--steps", "--start=geocode", "--end=geocode"],
cwd=work_dir,
log_path=work_dir / "03_geocode.log",
)
outputs = export_products(
work_dir=work_dir,
output_dir=output_dir,
prefix=output_prefix,
wavelength=args.wavelength,
coh_threshold=args.coh_threshold,
include_disp_full=args.include_disp_full,
)
print("Pipeline finished.")
for key, path in outputs.items():
print(f"{key}: {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())