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()