Spaces:
Running
Running
File size: 10,889 Bytes
d70361b | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 | """
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()
|