"""Warp two neighboring ECW orthos onto the same overlap grid for DDA testing. The app cannot read .ecw (rasterio has no ECW driver). Neighboring tiles (e.g. 0-23 vs 0-24) also fail naive NCC because they cover different ground. This script uses QGIS GDAL (ECW plugin) to: 1. read bounds 2. crop both to the geographic intersection (optional center window) 3. write matching GeoTIFFs (same CRS, pixel size, size) Usage: python scripts/prep_ecw_overlap_pair.py python scripts/prep_ecw_overlap_pair.py --before PATH --after PATH --out DIR """ from __future__ import annotations import argparse import json import os import subprocess import sys from pathlib import Path ROOT = Path(__file__).resolve().parents[1] DEFAULT_BEFORE = Path(r"c:\Users\udayb\Downloads\0-24_ori _26-02-25.ecw") DEFAULT_AFTER = Path(r"c:\Users\udayb\Downloads\0-23_ori_01-03-25.ecw") DEFAULT_OUT = ROOT / "data" / "library_sources" / "central_delhi" / "Images" QGIS_ROOT = Path(r"C:\Program Files\QGIS 4.0.2") def _qgis_env() -> dict: env = os.environ.copy() bin_dir = str(QGIS_ROOT / "bin") env["PATH"] = bin_dir + os.pathsep + env.get("PATH", "") env["GDAL_DRIVER_PATH"] = str(QGIS_ROOT / "apps" / "gdal" / "lib" / "gdalplugins") env["PROJ_LIB"] = str(QGIS_ROOT / "share" / "proj") gdal_data = QGIS_ROOT / "apps" / "gdal" / "share" / "gdal" if not gdal_data.is_dir(): gdal_data = QGIS_ROOT / "share" / "gdal" if gdal_data.is_dir(): env["GDAL_DATA"] = str(gdal_data) return env def _gdalinfo(path: Path) -> dict: exe = QGIS_ROOT / "bin" / "gdalinfo.exe" raw = subprocess.check_output( [str(exe), "-json", str(path)], env=_qgis_env(), text=True ) return json.loads(raw) def _extent(info: dict) -> tuple[float, float, float, float]: c = info["cornerCoordinates"] xs = [c["upperLeft"][0], c["lowerLeft"][0], c["upperRight"][0], c["lowerRight"][0]] ys = [c["upperLeft"][1], c["lowerLeft"][1], c["upperRight"][1], c["lowerRight"][1]] return min(xs), min(ys), max(xs), max(ys) def _intersect(a, b): xmin = max(a[0], b[0]) ymin = max(a[1], b[1]) xmax = min(a[2], b[2]) ymax = min(a[3], b[3]) if xmax <= xmin or ymax <= ymin: raise SystemExit("No geographic overlap — these are not the same scene.") return xmin, ymin, xmax, ymax def _center_window(ext, height_m: float | None): xmin, ymin, xmax, ymax = ext if not height_m or height_m <= 0: return ext cy = 0.5 * (ymin + ymax) half = height_m / 2.0 ymin2 = max(ymin, cy - half) ymax2 = min(ymax, cy + half) return xmin, ymin2, xmax, ymax2 def _warp(src: Path, dst: Path, te, tr: float) -> None: exe = QGIS_ROOT / "bin" / "gdalwarp.exe" xmin, ymin, xmax, ymax = te cmd = [ str(exe), "-overwrite", "-t_srs", "EPSG:32643", "-te", str(xmin), str(ymin), str(xmax), str(ymax), "-tr", str(tr), str(tr), "-r", "bilinear", "-of", "GTiff", "-co", "TILED=YES", "-co", "COMPRESS=LZW", "-dstalpha", str(src), str(dst), ] subprocess.check_call(cmd, env=_qgis_env()) def crop_valid_overlap(before_path: Path, after_path: Path) -> None: """Drop nodata/alpha so the app's global NCC is measured on real overlap.""" import numpy as np import rasterio from rasterio.windows import Window with rasterio.open(before_path) as db, rasterio.open(after_path) as da: b = db.read() a = da.read() if b.shape[0] >= 4: valid_b = b[3] > 0 rgb_b = b[:3] else: valid_b = np.any(b[:3] > 5, axis=0) rgb_b = b[:3] if a.shape[0] >= 4: valid_a = a[3] > 0 rgb_a = a[:3] else: valid_a = np.any(a[:3] > 5, axis=0) rgb_a = a[:3] both = valid_b & valid_a & np.any(rgb_b > 5, axis=0) & np.any(rgb_a > 5, axis=0) rows = np.any(both, axis=1) cols = np.any(both, axis=0) if not rows.any() or not cols.any(): raise SystemExit("No jointly valid pixels after warp") r0, r1 = int(np.argmax(rows)), int(len(rows) - np.argmax(rows[::-1])) c0, c1 = int(np.argmax(cols)), int(len(cols) - np.argmax(cols[::-1])) window = Window(c0, r0, c1 - c0, r1 - r0) transform = db.window_transform(window) profile = db.profile.copy() profile.update( count=3, height=r1 - r0, width=c1 - c0, transform=transform, photometric="RGB", ) profile.pop("nbits", None) out_b = rgb_b[:, r0:r1, c0:c1] out_a = rgb_a[:, r0:r1, c0:c1] with rasterio.open(before_path, "w", **profile) as dst: dst.write(out_b) with rasterio.open(after_path, "w", **profile) as dst: dst.write(out_a) print(f"cropped valid overlap to {c1 - c0} x {r1 - r0} px") def main() -> None: p = argparse.ArgumentParser() p.add_argument("--before", type=Path, default=DEFAULT_BEFORE) p.add_argument("--after", type=Path, default=DEFAULT_AFTER) p.add_argument("--out", type=Path, default=DEFAULT_OUT) p.add_argument("--gsd", type=float, default=0.03, help="Output metres/pixel") p.add_argument( "--center-height-m", type=float, default=120.0, help="Keep this many metres of N-S overlap (0 = full strip)", ) args = p.parse_args() if not args.before.is_file() or not args.after.is_file(): raise SystemExit("ECW files not found") if not (QGIS_ROOT / "bin" / "gdalwarp.exe").is_file(): raise SystemExit(f"QGIS GDAL not found at {QGIS_ROOT}") info_b = _gdalinfo(args.before) info_a = _gdalinfo(args.after) ext = _center_window(_intersect(_extent(info_b), _extent(info_a)), args.center_height_m) w = ext[2] - ext[0] h = ext[3] - ext[1] args.out.mkdir(parents=True, exist_ok=True) before_out = args.out / "ecw_overlap_before_2025-02-26.tif" after_out = args.out / "ecw_overlap_after_2025-03-01.tif" print(f"overlap window {w:.2f} x {h:.2f} m @ {args.gsd} m/px -> {before_out.name}") _warp(args.before, before_out, ext, args.gsd) _warp(args.after, after_out, ext, args.gsd) crop_valid_overlap(before_out, after_out) sys.path.insert(0, str(ROOT)) import cv2 from app.detection_engine import _alignment_ncc def load(path: Path): im = cv2.imread(str(path), cv2.IMREAD_COLOR) return cv2.cvtColor(im, cv2.COLOR_BGR2RGB) rb, ra = load(before_out), load(after_out) ncc = float(_alignment_ncc(rb, ra)) meta = { "before_src": str(args.before), "after_src": str(args.after), "extent_utm43n": list(ext), "gsd_m": args.gsd, "size_px": [int(rb.shape[1]), int(rb.shape[0])], "ncc_full": round(ncc, 4), "ncc_valid_pixels": round(ncc, 4), "registration_gate": 0.55, "fit_for_detection": bool(ncc >= 0.55), "note": ( "Neighboring tiles cropped to shared ground. Dates are 3 days apart " "so expect little real construction change; pair is for alignment/pipeline tests." ), } (args.out / "ecw_overlap_pair.json").write_text( json.dumps(meta, indent=2), encoding="utf-8" ) print(json.dumps(meta, indent=2)) if not meta["fit_for_detection"]: raise SystemExit("NCC still below 0.55 — pair not fit") if __name__ == "__main__": main()