satdetect-dev / scripts /compare_methods.py
coderuday21's picture
Cursor
Deploy satdetect-dev with Priyanka audit/exception logs.
d70361b
Raw
History Blame Contribute Delete
10.9 kB
"""
Compare change-detection methods, sensitivities, and fusion modes on a real
before/after image pair, so tuning is based on measurements instead of
guesswork (Phase 3/4 of the accuracy remediation plan).
Without --gt, reports timing/change%/region-count/alignment per config so you
can eyeball which setting looks right for your imagery. With --gt (a binary
PNG mask of the real changed pixels, white = changed), also reports
IoU/Dice/F1/Precision/Recall per config for an objective ranking.
Usage:
python scripts/compare_methods.py --before before.png --after after.png
python scripts/compare_methods.py --before b.tif --after a.tif --gt truth.png
python scripts/compare_methods.py --before b.png --after a.png --out runs/compare
python scripts/compare_methods.py --before b.png --after a.png \
--methods "AI-Based Deep Learning,Feature-Based" --sensitivities 0.3,0.5,0.7 \
--fusions smart_union,hysteresis
# Batch mode: run every pair in a Delhi eval manifest (docs/delhi_eval/manifest.json).
# Uses each pair's gt_mask when labeled, otherwise runs without ground truth.
python scripts/compare_methods.py --manifest docs/delhi_eval/manifest.json --out runs/manifest_scan
"""
import argparse
import json
import os
import sys
import time
from pathlib import Path
import numpy as np
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
try:
from dotenv import load_dotenv
load_dotenv(ROOT / ".env")
except ImportError:
pass
from app.detection_engine import run_detection # noqa: E402
from app.evaluation.metrics import binary_metrics # noqa: E402
ALL_METHODS = ["AI-Based Deep Learning", "Feature-Based", "Hybrid Approach", "Hybrid AI",
"KPCA (Unsupervised)"]
FUSION_CAPABLE = {"AI-Based Deep Learning", "Hybrid AI"}
def _load_image(path: Path):
"""Load an image the same way the app does — GeoTIFFs go through rasterio's
decimated read (never materializes the full-res array), so a multi-GB
satellite raster loads safely instead of PIL trying to decode it whole."""
if path.suffix.lower() in (".tif", ".tiff"):
from app.dda.geotiff_io import load_rgb_pil
return load_rgb_pil(path)
return Image.open(path).convert("RGB")
def _run_one(before, after, method, sensitivity, fusion, gt, before_path=None, after_path=None):
if fusion:
os.environ["DETECTION_FUSION"] = fusion
t0 = time.time()
mask, _img, stats, regions = run_detection(
before, after, method=method,
enable_registration=True, enable_normalization=True,
detection_sensitivity=sensitivity,
before_path=before_path, after_path=after_path,
)
elapsed = time.time() - t0
td = stats.get("threshold_debug", {}) or {}
tile_skip = td.get("tileSkip") or {}
row = {
"method": method,
"sensitivity": sensitivity,
"fusion": fusion or td.get("fusionMode", "-"),
"seconds": round(elapsed, 1),
"changePct": round(stats["change_percentage"], 3),
"regions": len(regions),
"alignmentOk": stats.get("alignment_warning") is None,
"tilesSkipped": f"{tile_skip.get('skippedTiles', 0)}/{tile_skip.get('totalTiles', 0)}"
if tile_skip.get("totalTiles") else "-",
}
if gt is not None:
m = binary_metrics(mask, gt)
row.update({"iou": m["iou"], "f1": m["f1"], "precision": m["precision"], "recall": m["recall"]})
return row, mask
def run_manifest(manifest_path: Path, methods, sensitivities, fusions, out_dir,
save_masks: bool = True):
"""Batch mode: run every pair listed in a Delhi eval manifest.json end-to-end.
Pairs without a labeled gt_mask still run (change%/regions only) so this can
be smoke-tested before any masks exist — that's the Day 1 wiring check.
"""
data = json.loads(manifest_path.read_text(encoding="utf-8"))
pairs = data.get("pairs", [])
if not pairs:
print(f"No pairs in {manifest_path} — nothing to run. "
f"Use scripts/build_delhi_manifest.py --add to log pairs first.")
return
print(f"Running {len(methods)} method(s) x {len(sensitivities)} sensitivity(ies) "
f"across {len(pairs)} manifest pair(s)...\n")
rows = []
for pair in pairs:
pair_id = pair["pair_id"]
before_path = ROOT / pair["before_path"]
after_path = ROOT / pair["after_path"]
if not before_path.exists() or not after_path.exists():
print(f" {pair_id}: SKIP (image missing on disk)")
continue
is_geotiff = before_path.suffix.lower() in (".tif", ".tiff")
try:
before = _load_image(before_path)
after = _load_image(after_path)
except Exception as exc: # noqa: BLE001 - report and keep going
print(f" {pair_id}: SKIP (failed to load: {exc})")
continue
gt = None
gt_rel = pair.get("gt_mask")
if gt_rel and (ROOT / gt_rel).exists():
gt = np.array(Image.open(ROOT / gt_rel).convert("L"))
for method in methods:
use_fusions = fusions if method in FUSION_CAPABLE else [None]
for sensitivity in sensitivities:
for fusion in use_fusions:
row, mask = _run_one(
before, after, method, sensitivity, fusion, gt,
before_path=str(before_path) if is_geotiff else None,
after_path=str(after_path) if is_geotiff else None,
)
row["pair_id"] = pair_id
rows.append(row)
tag = f"{pair_id}_{method}_s{sensitivity}" + (f"_{fusion}" if fusion else "")
tag = tag.replace(" ", "_").replace("/", "-")
extra = " ".join(
f"{k}={v}" for k, v in row.items()
if k not in ("method", "sensitivity", "fusion", "pair_id")
)
print(f" {tag:60s} {extra}")
if out_dir and save_masks:
Image.fromarray(mask).save(out_dir / f"{tag}_mask.png")
if out_dir:
(out_dir / "manifest_report.json").write_text(json.dumps(rows, indent=2), encoding="utf-8")
print(f"\nWrote {len(rows)} row(s) to {out_dir / 'manifest_report.json'}")
labeled_rows = [r for r in rows if "iou" in r]
if labeled_rows:
mean_iou = sum(r["iou"] for r in labeled_rows) / len(labeled_rows)
mean_f1 = sum(r["f1"] for r in labeled_rows) / len(labeled_rows)
print(f"\nMean over {len(labeled_rows)} labeled row(s): IoU={mean_iou:.3f} F1={mean_f1:.3f}")
else:
print(f"\nNo labeled pairs yet ({len(rows)} row(s) ran without ground truth) — "
f"add masks to docs/delhi_eval/labels/ to get IoU/F1.")
def main():
parser = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--before", default="", help="path to the 'before' image")
parser.add_argument("--after", default="", help="path to the 'after' image")
parser.add_argument("--gt", default="", help="optional ground-truth binary change mask PNG")
parser.add_argument("--manifest", default="",
help="path to a Delhi eval manifest.json — batch-runs every pair instead "
"of a single --before/--after image")
parser.add_argument("--methods", default=",".join(ALL_METHODS))
parser.add_argument("--sensitivities", default="0.3,0.5,0.7")
parser.add_argument("--fusions", default="",
help="comma list e.g. smart_union,hysteresis (AI methods only); "
"blank = use current DETECTION_FUSION env/default only")
parser.add_argument("--out", default="", help="dir to save per-config mask PNGs + report JSON")
parser.add_argument("--report-only", action="store_true",
help="manifest/batch mode: write JSON report only, skip mask PNGs")
args = parser.parse_args()
methods = [m.strip() for m in args.methods.split(",") if m.strip()]
sensitivities = [float(s) for s in args.sensitivities.split(",") if s.strip()]
fusions = [f.strip() for f in args.fusions.split(",") if f.strip()] or [None]
out_dir = Path(args.out).resolve() if args.out else None
if out_dir:
out_dir.mkdir(parents=True, exist_ok=True)
if args.manifest:
run_manifest(Path(args.manifest), methods, sensitivities, fusions, out_dir,
save_masks=not args.report_only)
return
if not args.before or not args.after:
sys.exit("Either --manifest, or both --before and --after, are required.")
before_path = Path(args.before)
after_path = Path(args.after)
is_geotiff = before_path.suffix.lower() in (".tif", ".tiff")
print(f"Loading images (GeoTIFFs are downsampled via rasterio, never fully decoded)...")
before = _load_image(before_path)
after = _load_image(after_path)
gt = np.array(Image.open(args.gt).convert("L")) if args.gt else None
print(f"Comparing on: before={args.before} after={args.after}"
f"{' gt=' + args.gt if args.gt else ' (no ground truth — compare by eye)'}\n")
rows = []
for method in methods:
use_fusions = fusions if method in FUSION_CAPABLE else [None]
for sensitivity in sensitivities:
for fusion in use_fusions:
row, mask = _run_one(
before, after, method, sensitivity, fusion, gt,
before_path=str(before_path) if is_geotiff else None,
after_path=str(after_path) if is_geotiff else None,
)
rows.append(row)
tag = f"{method}_s{sensitivity}" + (f"_{fusion}" if fusion else "")
tag = tag.replace(" ", "_").replace("/", "-")
extra = " ".join(
f"{k}={v}" for k, v in row.items()
if k not in ("method", "sensitivity", "fusion")
)
print(f" {tag:50s} {extra}")
if out_dir:
Image.fromarray(mask).save(out_dir / f"{tag}_mask.png")
if out_dir:
(out_dir / "compare_report.json").write_text(json.dumps(rows, indent=2), encoding="utf-8")
print(f"\nWrote masks + compare_report.json to {out_dir}")
if gt is not None:
best = max(rows, key=lambda r: r["iou"])
print(f"\nBest by IoU: {best}")
else:
print("\nNo --gt supplied — pick the config whose change% / region count / overlay "
"(check --out masks) best matches what you know actually changed on the ground.")
if __name__ == "__main__":
main()