Spaces:
Sleeping
Sleeping
File size: 7,787 Bytes
ba4abf7 99e1f27 ba4abf7 99e1f27 ba4abf7 99e1f27 ba4abf7 99e1f27 ba4abf7 99e1f27 ba4abf7 99e1f27 ba4abf7 | 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 | """
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()
|