satdetect-dev / scripts /validate_detection.py
coderuday21's picture
Improve detection accuracy with full-res tiling, config flags, and evaluation.
99e1f27
Raw
History Blame Contribute Delete
7.79 kB
"""
Lightweight validation + accuracy benchmark for the change detection pipeline.
Run from change_detection_webapp:
python scripts/validate_detection.py # unit checks
python scripts/validate_detection.py --benchmark # synthetic IoU/Dice/F1 report
python scripts/validate_detection.py --benchmark --out runs/eval # + comparison PNGs
Benchmarks run on synthetic pairs with known ground-truth change masks, so they
work even without labeled imagery (per project decision: synthetic + review
labels for now). The same harness is used to A/B preprocessing and fusion
toggles by setting the relevant DETECTION_* env vars before running.
"""
import argparse
import json
import sys
from pathlib import Path
import numpy as np
from PIL import Image
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
from app.detection_engine import ( # noqa: E402
register_images,
run_detection,
fuse_dl_and_classical,
)
from app.evaluation.metrics import binary_metrics # noqa: E402
def test_registration_identical_pair():
rng = np.random.default_rng(42)
img = rng.integers(0, 255, (320, 320, 3), dtype=np.uint8)
b, a, ok, meta = register_images(img, img.copy())
assert meta.get("ncc", 0) >= 0.5 or ok, f"weak NCC on identical pair: {meta}"
print(" registration identical pair:", ok, meta)
def test_registration_with_shift():
img = np.zeros((400, 400, 3), dtype=np.uint8)
img[80:200, 80:200] = [180, 90, 60]
shifted = np.roll(np.roll(img, 8, axis=0), 5, axis=1)
b, a, ok, meta = register_images(img, shifted)
print(" registration shifted pair:", ok, "ncc=", meta.get("ncc"))
def test_fusion_shapes():
h, w = 128, 128
dl = np.zeros((h, w), dtype=np.float32)
dl[40:80, 40:80] = 0.8
cl = np.zeros((h, w), dtype=np.float32)
cl[50:90, 50:90] = 0.7
img = np.full((h, w, 3), 128, dtype=np.uint8)
mask, score, dbg = fuse_dl_and_classical(dl, cl, img, img, sensitivity=0.5)
assert mask.shape == (h, w)
assert score.shape == (h, w)
assert dbg.get("fused_changed_px", 0) >= 0
print(" fusion:", dbg.get("fused_changed_px"), "px")
def test_run_detection_synthetic():
rng = np.random.default_rng(0)
before = rng.integers(0, 255, (256, 256, 3), dtype=np.uint8)
after = before.copy()
after[100:180, 100:180] = [40, 180, 40]
mask, _, stats, regions = run_detection(
Image.fromarray(before),
Image.fromarray(after),
method="AI-Based Deep Learning",
enable_registration=True,
enable_normalization=True,
detection_sensitivity=0.5,
)
assert mask.shape[:2] == (256, 256)
ratio = stats["change_percentage"]
assert 0 <= ratio <= 100
assert "params" in stats
assert not stats.get("threshold_debug", {}).get("fallback_used", False)
print(" run_detection: change%=", f"{ratio:.2f}", "regions=", len(regions))
# ---------------------------------------------------------------------------
# Synthetic benchmark suite (known ground-truth masks)
# ---------------------------------------------------------------------------
def _base_scene(size=384, seed=0):
"""A textured pseudo-aerial scene so registration/feature matching has signal."""
rng = np.random.default_rng(seed)
img = rng.integers(40, 200, (size, size, 3), dtype=np.uint8)
img = np.array(Image.fromarray(img).resize((size, size)))
# A few stable structures (roads / fields) common to both timestamps
img[:, size // 3: size // 3 + 6] = [90, 90, 90]
img[size // 2: size // 2 + 6, :] = [110, 100, 80]
return img
def _case_inserted_buildings(size=384):
before = _base_scene(size, seed=1)
after = before.copy()
gt = np.zeros((size, size), dtype=np.uint8)
boxes = [(60, 70, 50, 40), (220, 90, 60, 55), (150, 250, 70, 45)]
for (x, y, w, h) in boxes:
after[y:y + h, x:x + w] = [205, 200, 190]
gt[y:y + h, x:x + w] = 255
return before, after, gt, "inserted_buildings"
def _case_brightness_only(size=384):
# Global illumination change with NO structural change -> GT empty, expect low FP.
before = _base_scene(size, seed=2)
after = np.clip(before.astype(np.float32) * 1.18 + 12, 0, 255).astype(np.uint8)
gt = np.zeros((size, size), dtype=np.uint8)
return before, after, gt, "brightness_only"
def _case_misaligned_change(size=384):
before = _base_scene(size, seed=3)
shifted = np.roll(np.roll(before, 6, axis=0), 4, axis=1)
after = shifted.copy()
gt = np.zeros((size, size), dtype=np.uint8)
x, y, w, h = 180, 160, 80, 60
after[y:y + h, x:x + w] = [210, 60, 60]
gt[y:y + h, x:x + w] = 255
return before, after, gt, "misaligned_change"
def _save_comparison(out_dir: Path, name: str, before, after, pred_mask, gt):
out_dir.mkdir(parents=True, exist_ok=True)
h, w = gt.shape
pred = (pred_mask > 127).astype(np.uint8) * 255
overlay = after.copy()
overlay[pred > 0] = (0.45 * overlay[pred > 0] + 0.55 * np.array([255, 40, 40])).astype(np.uint8)
diff = np.zeros((h, w, 3), dtype=np.uint8)
diff[(pred > 0) & (gt > 0)] = [0, 200, 0] # true positive
diff[(pred > 0) & (gt == 0)] = [255, 0, 0] # false positive
diff[(pred == 0) & (gt > 0)] = [0, 0, 255] # false negative
panels = [
before, after,
np.dstack([gt] * 3), overlay, diff,
]
strip = np.concatenate([np.asarray(p, dtype=np.uint8) for p in panels], axis=1)
Image.fromarray(strip).save(out_dir / f"{name}.png")
def benchmark_synthetic(out_dir: Path | None = None, sensitivity=0.5):
cases = [_case_inserted_buildings(), _case_brightness_only(), _case_misaligned_change()]
report = {}
print("\nSynthetic benchmark (IoU / Dice / F1 / Precision / Recall):")
for before, after, gt, name in cases:
mask, _img, stats, regions = run_detection(
Image.fromarray(before), Image.fromarray(after),
method="AI-Based Deep Learning",
enable_registration=True, enable_normalization=True,
detection_sensitivity=sensitivity,
)
if mask.shape != gt.shape:
from cv2 import resize, INTER_NEAREST
mask = resize(mask, (gt.shape[1], gt.shape[0]), interpolation=INTER_NEAREST)
m = binary_metrics(mask, gt)
report[name] = {"metrics": m, "regions": len(regions),
"changePct": round(stats["change_percentage"], 3)}
print(f" {name:20s} IoU={m['iou']:.3f} Dice={m['dice']:.3f} "
f"F1={m['f1']:.3f} P={m['precision']:.3f} R={m['recall']:.3f} "
f"FPR={m['falsePositiveRate']:.3f}")
if out_dir is not None:
_save_comparison(out_dir, name, before, after, mask, gt)
if out_dir is not None:
(out_dir / "metrics.json").write_text(json.dumps(report, indent=2), encoding="utf-8")
print(f"\n Wrote comparison images + metrics.json to {out_dir}")
return report
def main():
parser = argparse.ArgumentParser(description="Validate + benchmark change detection")
parser.add_argument("--benchmark", action="store_true", help="run synthetic accuracy benchmark")
parser.add_argument("--out", type=str, default="", help="dir for comparison images + metrics.json")
parser.add_argument("--sensitivity", type=float, default=0.5)
args = parser.parse_args()
print("validate_detection.py")
test_registration_identical_pair()
test_registration_with_shift()
test_fusion_shapes()
test_run_detection_synthetic()
print("All checks passed.")
if args.benchmark:
out_dir = Path(args.out).resolve() if args.out else None
benchmark_synthetic(out_dir=out_dir, sensitivity=args.sensitivity)
if __name__ == "__main__":
main()