Spaces:
Running
Running
Commit ·
ba4abf7
1
Parent(s): 3e1a5d9
Fix detection precision: gated fusion, strict registration, calibrated thresholds, preload model
Browse files- Dockerfile +1 -1
- app/cd_models/change_model.py +7 -2
- app/detection_engine.py +231 -196
- app/main.py +7 -0
- app/model_inference.py +46 -25
- scripts/validate_detection.py +82 -0
- static/css/style.css +10 -0
- static/js/app.js +19 -1
- templates/index.html +5 -2
Dockerfile
CHANGED
|
@@ -19,7 +19,7 @@ WORKDIR /app
|
|
| 19 |
|
| 20 |
# Build-time info + cache-bust:
|
| 21 |
# Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
|
| 22 |
-
ARG APP_BUILD=
|
| 23 |
ENV APP_BUILD=${APP_BUILD}
|
| 24 |
RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
|
| 25 |
|
|
|
|
| 19 |
|
| 20 |
# Build-time info + cache-bust:
|
| 21 |
# Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
|
| 22 |
+
ARG APP_BUILD=21
|
| 23 |
ENV APP_BUILD=${APP_BUILD}
|
| 24 |
RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
|
| 25 |
|
app/cd_models/change_model.py
CHANGED
|
@@ -126,13 +126,18 @@ def _build_model():
|
|
| 126 |
# Model loading (singleton)
|
| 127 |
# ---------------------------------------------------------------------------
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
def is_siamese_available():
|
| 130 |
-
"""
|
| 131 |
global _AVAILABLE
|
| 132 |
if _AVAILABLE is not None:
|
| 133 |
return _AVAILABLE
|
| 134 |
torch, _ = _try_torch()
|
| 135 |
-
_AVAILABLE = torch is not None
|
| 136 |
return _AVAILABLE
|
| 137 |
|
| 138 |
|
|
|
|
| 126 |
# Model loading (singleton)
|
| 127 |
# ---------------------------------------------------------------------------
|
| 128 |
|
| 129 |
+
def has_siamese_weights():
|
| 130 |
+
"""True only when a trained weights file is present."""
|
| 131 |
+
return _WEIGHTS_FILE.is_file()
|
| 132 |
+
|
| 133 |
+
|
| 134 |
def is_siamese_available():
|
| 135 |
+
"""PyTorch installed and pretrained weights available."""
|
| 136 |
global _AVAILABLE
|
| 137 |
if _AVAILABLE is not None:
|
| 138 |
return _AVAILABLE
|
| 139 |
torch, _ = _try_torch()
|
| 140 |
+
_AVAILABLE = torch is not None and has_siamese_weights()
|
| 141 |
return _AVAILABLE
|
| 142 |
|
| 143 |
|
app/detection_engine.py
CHANGED
|
@@ -38,8 +38,8 @@ def _to_float32(img):
|
|
| 38 |
return img.astype(np.float32) / 255.0
|
| 39 |
|
| 40 |
|
| 41 |
-
def preprocess_image(image, max_size=
|
| 42 |
-
"""Preprocess image: convert to RGB, limit size, Gaussian
|
| 43 |
img_array = np.array(image)
|
| 44 |
img_array = _ensure_rgb_uint8(img_array)
|
| 45 |
|
|
@@ -49,10 +49,11 @@ def preprocess_image(image, max_size=2000):
|
|
| 49 |
new_w, new_h = max(1, int(width * scale)), max(1, int(height * scale))
|
| 50 |
img_array = cv2.resize(img_array, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
| 51 |
|
| 52 |
-
# Gaussian smoothing to reduce high-frequency sensor noise
|
| 53 |
img_array = cv2.GaussianBlur(img_array, (5, 5), 0)
|
| 54 |
-
|
| 55 |
-
|
|
|
|
|
|
|
| 56 |
return img_array
|
| 57 |
|
| 58 |
|
|
@@ -122,15 +123,42 @@ def _match_features_orb(gray1, gray2, max_features=3000):
|
|
| 122 |
return best_H, best_ir
|
| 123 |
|
| 124 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
def register_images(img1, img2, max_features=3000):
|
| 126 |
"""
|
| 127 |
-
Multi-stage
|
| 128 |
-
|
| 129 |
-
2. ORB fallback (if SIFT unavailable or fails)
|
| 130 |
-
3. Refine with ECC for sub-pixel accuracy
|
| 131 |
-
4. Multi-scale ECC fallback if feature matching fails entirely
|
| 132 |
"""
|
| 133 |
h, w = img1.shape[:2]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 134 |
|
| 135 |
if img1.shape[:2] != img2.shape[:2]:
|
| 136 |
img2 = cv2.resize(img2, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
@@ -138,23 +166,36 @@ def register_images(img1, img2, max_features=3000):
|
|
| 138 |
gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY)
|
| 139 |
gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY)
|
| 140 |
|
| 141 |
-
|
| 142 |
-
|
| 143 |
|
| 144 |
-
|
|
|
|
| 145 |
if H is None or ir < 0.25:
|
| 146 |
H_orb, ir_orb = _match_features_orb(gray1, gray2, max_features)
|
| 147 |
if ir_orb > ir:
|
| 148 |
H, ir = H_orb, ir_orb
|
|
|
|
| 149 |
|
| 150 |
-
|
| 151 |
-
img2_warped = cv2.warpPerspective(img2, H, (w, h),
|
| 152 |
-
borderMode=cv2.BORDER_REFLECT)
|
| 153 |
-
img2_refined = _refine_ecc(img1, img2_warped)
|
| 154 |
-
return img1, img2_refined, True
|
| 155 |
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
| 159 |
|
| 160 |
def _refine_ecc(img1, img2_initial):
|
|
@@ -240,10 +281,14 @@ def _register_images_ecc_multiscale(img1, img2):
|
|
| 240 |
g_aligned = cv2.cvtColor(aligned, cv2.COLOR_RGB2GRAY).astype(np.float32)
|
| 241 |
g_ref = gray1.astype(np.float32)
|
| 242 |
ncc = float(np.corrcoef(g_ref.ravel(), g_aligned.ravel())[0, 1])
|
| 243 |
-
|
| 244 |
-
|
|
|
|
|
|
|
| 245 |
except Exception:
|
| 246 |
-
return img1, img2, False
|
|
|
|
|
|
|
| 247 |
|
| 248 |
|
| 249 |
# ---------------------------------------------------------------------------
|
|
@@ -251,7 +296,7 @@ def _register_images_ecc_multiscale(img1, img2):
|
|
| 251 |
# ---------------------------------------------------------------------------
|
| 252 |
|
| 253 |
def normalize_radiometry(img1, img2):
|
| 254 |
-
"""
|
| 255 |
lab1 = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 256 |
lab2 = cv2.cvtColor(img2, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 257 |
|
|
@@ -262,17 +307,13 @@ def normalize_radiometry(img1, img2):
|
|
| 262 |
if std2 > 1e-6:
|
| 263 |
result[:, :, ch] = (lab2[:, :, ch] - mean2) * (std1 / std2) + mean1
|
| 264 |
|
| 265 |
-
result_uint8 = np.clip(result, 0, 255).astype(np.uint8)
|
| 266 |
-
|
| 267 |
-
# CLAHE on L channel of BOTH images so downstream comparison is symmetric
|
| 268 |
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
|
|
|
| 272 |
|
| 273 |
-
|
| 274 |
-
img2_out = cv2.cvtColor(result_uint8, cv2.COLOR_LAB2RGB)
|
| 275 |
-
return img1_out, img2_out
|
| 276 |
|
| 277 |
|
| 278 |
# ---------------------------------------------------------------------------
|
|
@@ -627,21 +668,16 @@ def _snr_weight(channel):
|
|
| 627 |
return signal / noise
|
| 628 |
|
| 629 |
|
| 630 |
-
def
|
| 631 |
-
"""
|
| 632 |
-
Single-pass AI fusion with 5 channels, SNR weighting, and
|
| 633 |
-
vegetation + shadow suppression. Returns (mask, debug).
|
| 634 |
-
"""
|
| 635 |
if img1.shape != img2.shape:
|
| 636 |
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 637 |
|
| 638 |
-
# ---- Channel 1: Multi-scale LAB color difference ----
|
| 639 |
lab1 = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 640 |
lab2 = cv2.cvtColor(img2, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 641 |
|
| 642 |
-
scales = [1, 2, 4]
|
| 643 |
color_maps = []
|
| 644 |
-
for scale in
|
| 645 |
if scale > 1:
|
| 646 |
s1 = cv2.resize(lab1, (lab1.shape[1] // scale, lab1.shape[0] // scale))
|
| 647 |
s2 = cv2.resize(lab2, (lab2.shape[1] // scale, lab2.shape[0] // scale))
|
|
@@ -658,21 +694,17 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5):
|
|
| 658 |
color_change = np.mean(color_maps, axis=0)
|
| 659 |
color_change = color_change / (color_change.max() + 1e-8)
|
| 660 |
|
| 661 |
-
# ---- Channel 2: SSIM structural dissimilarity ----
|
| 662 |
ssim_change = compute_ssim_change_map(img1, img2)
|
| 663 |
ssim_change = ssim_change / (ssim_change.max() + 1e-8)
|
| 664 |
-
|
| 665 |
-
# ---- Channel 3: Texture change (LBP) ----
|
| 666 |
texture_change = compute_texture_change(img1, img2)
|
| 667 |
texture_change = texture_change / (texture_change.max() + 1e-8)
|
| 668 |
-
|
| 669 |
-
# ---- Channel 4: Edge change ----
|
| 670 |
edge_change = compute_edge_change(img1, img2)
|
| 671 |
-
|
| 672 |
-
# ---- Channel 5: Change Vector Analysis ----
|
| 673 |
cva_change = compute_cva(img1, img2)
|
| 674 |
|
| 675 |
-
|
|
|
|
|
|
|
|
|
|
| 676 |
channels = [color_change, ssim_change, texture_change, edge_change, cva_change]
|
| 677 |
weights = [_snr_weight(ch) for ch in channels]
|
| 678 |
total_w = sum(weights) + 1e-8
|
|
@@ -682,42 +714,76 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5):
|
|
| 682 |
for ch, w in zip(channels, weights):
|
| 683 |
fused += w * ch.astype(np.float64)
|
| 684 |
|
| 685 |
-
# ---- Apply vegetation + shadow suppression before thresholding ----
|
| 686 |
veg_suppression = compute_combined_vegetation_suppression(img1, img2)
|
| 687 |
shadow_suppression = compute_shadow_suppression(img1, img2)
|
| 688 |
fused = fused * veg_suppression.astype(np.float64) * shadow_suppression.astype(np.float64)
|
| 689 |
|
| 690 |
-
# Percentile normalization
|
| 691 |
p995 = float(np.quantile(fused, 0.995))
|
| 692 |
if p995 <= 1e-8:
|
| 693 |
p995 = float(fused.max() + 1e-8)
|
| 694 |
fused_norm = np.clip(fused / (p995 + 1e-8), 0.0, 1.0)
|
|
|
|
|
|
|
| 695 |
|
| 696 |
-
gamma = 0.85
|
| 697 |
-
fused_norm = np.power(fused_norm, gamma)
|
| 698 |
-
|
| 699 |
-
fused_smooth = cv2.GaussianBlur(fused_norm.astype(np.float32), (7, 7), 0)
|
| 700 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 701 |
sens = float(np.clip(sensitivity, 0.0, 1.0))
|
| 702 |
-
|
| 703 |
-
|
|
|
|
| 704 |
|
| 705 |
-
|
| 706 |
-
|
| 707 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 708 |
change_mask = _clean_mask(change_mask, sensitivity=sens)
|
| 709 |
|
| 710 |
-
|
| 711 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 712 |
|
| 713 |
debug = {
|
| 714 |
"method": "AI-Core",
|
| 715 |
"threshold_used": int(thr_score * 255),
|
| 716 |
"threshold_percentile_q": q,
|
| 717 |
"threshold_score": thr_score,
|
| 718 |
-
"fused_p95": float(np.quantile(fused_smooth, 0.95)),
|
| 719 |
-
"fused_p99": float(np.quantile(fused_smooth, 0.99)),
|
| 720 |
-
"fused_mean": float(np.mean(fused_smooth)),
|
| 721 |
"sensitivity": float(sensitivity),
|
| 722 |
"channel_weights": {
|
| 723 |
"color": round(weights[0], 4),
|
|
@@ -727,86 +793,70 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5):
|
|
| 727 |
"cva": round(weights[4], 4),
|
| 728 |
},
|
| 729 |
}
|
| 730 |
-
return change_mask, debug
|
| 731 |
|
| 732 |
|
| 733 |
-
def ai_deep_learning_method(img1, img2, sensitivity=0.5):
|
| 734 |
-
"""
|
| 735 |
-
Dual-engine approach:
|
| 736 |
-
1. AdaptFormer model (excellent for buildings/structural changes)
|
| 737 |
-
2. Rule-based multi-channel fusion (catches vegetation, texture, color changes)
|
| 738 |
-
Combines both via union to maximize recall for all change types.
|
| 739 |
-
"""
|
| 740 |
from .model_inference import is_model_available, predict_change_mask
|
| 741 |
-
import logging
|
| 742 |
-
_log = logging.getLogger(__name__)
|
| 743 |
|
| 744 |
-
|
| 745 |
model_ok = False
|
|
|
|
| 746 |
|
| 747 |
if is_model_available():
|
| 748 |
-
threshold = 0.25 + (1.0 - sensitivity) * 0.25
|
| 749 |
try:
|
| 750 |
-
|
| 751 |
-
|
| 752 |
-
model_mask = _clean_mask(model_mask, sensitivity=sensitivity)
|
| 753 |
-
model_ok = True
|
| 754 |
except Exception as e:
|
| 755 |
_log.warning("AdaptFormer inference failed: %s", e)
|
| 756 |
|
| 757 |
-
|
| 758 |
-
|
| 759 |
|
| 760 |
-
if model_ok and
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
combined = _clean_mask(combined, sensitivity=sensitivity)
|
| 764 |
debug = {
|
| 765 |
-
"method": "AI-Based Deep Learning (AdaptFormer +
|
| 766 |
"model": "adaptformer-levir-cd",
|
| 767 |
-
"threshold_used": int(
|
| 768 |
"sensitivity": float(sensitivity),
|
| 769 |
-
|
| 770 |
-
"rule_changed_px": int(np.sum(rule_mask > 127)),
|
| 771 |
-
"combined_changed_px": int(np.sum(combined > 127)),
|
| 772 |
}
|
| 773 |
return combined, debug
|
| 774 |
|
|
|
|
|
|
|
| 775 |
debug = {
|
| 776 |
-
"method": "AI-Based Deep Learning (
|
| 777 |
-
"threshold_used": core_debug.get("threshold_used"),
|
| 778 |
"sensitivity": float(sensitivity),
|
| 779 |
"core": core_debug,
|
| 780 |
}
|
| 781 |
return rule_mask, debug
|
| 782 |
|
| 783 |
|
| 784 |
-
def hybrid_method(img1, img2, sensitivity=0.5):
|
| 785 |
"""Hybrid: weighted fusion of all methods with confidence-based merging."""
|
| 786 |
if img1.shape != img2.shape:
|
| 787 |
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 788 |
|
| 789 |
diff_mask, diff_debug = image_difference_method(img1, img2, sensitivity=sensitivity)
|
| 790 |
-
feature_mask = feature_based_method(img1, img2)
|
| 791 |
-
ai_mask, ai_debug = ai_deep_learning_method(
|
|
|
|
| 792 |
|
| 793 |
-
# Weighted combination: AI method gets most weight
|
| 794 |
combined = (
|
| 795 |
0.2 * diff_mask.astype(np.float32) +
|
| 796 |
0.3 * feature_mask.astype(np.float32) +
|
| 797 |
0.5 * ai_mask.astype(np.float32)
|
| 798 |
)
|
| 799 |
|
| 800 |
-
|
| 801 |
-
# - diff only: 0.2*255 ≈ 51
|
| 802 |
-
# - feature only: 0.3*255 ≈ 76
|
| 803 |
-
# - ai only: 0.5*255 ≈ 127
|
| 804 |
-
# Keep threshold low enough that ai-only regions can pass.
|
| 805 |
-
base_thr = 98
|
| 806 |
sens = float(np.clip(sensitivity, 0.0, 1.0))
|
| 807 |
-
hybrid_thr = int(np.clip(base_thr + int((0.5 - sens) * 36),
|
| 808 |
_, final_mask = cv2.threshold(combined.astype(np.uint8), hybrid_thr, 255, cv2.THRESH_BINARY)
|
| 809 |
-
final_mask = _clean_mask(final_mask)
|
| 810 |
debug = {
|
| 811 |
"method": "Hybrid Approach",
|
| 812 |
"threshold_used": int(hybrid_thr),
|
|
@@ -851,87 +901,55 @@ def _build_confidence_map_from_channels(img1, img2, dl_score=None):
|
|
| 851 |
return build_confidence_map(channels, weights)
|
| 852 |
|
| 853 |
|
| 854 |
-
def
|
| 855 |
-
"""
|
| 856 |
-
from .cd_models.model_utils import multiscale_detect
|
| 857 |
-
|
| 858 |
-
def _single_scale_detect(s1, s2):
|
| 859 |
-
mask, _ = _ai_fusion_core(s1, s2, sensitivity=sensitivity)
|
| 860 |
-
return mask
|
| 861 |
-
|
| 862 |
-
return multiscale_detect(_single_scale_detect, img1, img2, scales=(1.0, 0.5))
|
| 863 |
-
|
| 864 |
-
|
| 865 |
-
def hybrid_ai_method(img1, img2, sensitivity=0.5):
|
| 866 |
-
"""
|
| 867 |
-
Hybrid AI mode: weighted fusion of deep-learning mask + multi-scale
|
| 868 |
-
classical mask, informed by a confidence map.
|
| 869 |
-
Pipeline:
|
| 870 |
-
1. Deep learning mask (AdaptFormer or Siamese U-Net)
|
| 871 |
-
2. Multi-scale classical mask (rule-based fusion at 1x + 0.5x)
|
| 872 |
-
3. Build per-pixel confidence map
|
| 873 |
-
4. Weighted combination: 0.7 * DL + 0.3 * classical → threshold
|
| 874 |
-
"""
|
| 875 |
if img1.shape != img2.shape:
|
| 876 |
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 877 |
|
| 878 |
-
# --- Deep learning mask ---
|
| 879 |
from .model_inference import is_model_available, predict_change_mask
|
| 880 |
-
dl_mask = np.zeros(img1.shape[:2], dtype=np.uint8)
|
| 881 |
-
dl_score = np.zeros(img1.shape[:2], dtype=np.float32)
|
| 882 |
-
dl_method = "none"
|
| 883 |
|
| 884 |
-
|
|
|
|
| 885 |
|
| 886 |
if is_model_available():
|
| 887 |
try:
|
| 888 |
-
|
| 889 |
dl_method = "adaptformer"
|
| 890 |
except Exception:
|
| 891 |
pass
|
| 892 |
|
| 893 |
if dl_method == "none":
|
| 894 |
try:
|
| 895 |
-
from .cd_models.change_model import
|
| 896 |
-
if
|
| 897 |
-
|
| 898 |
dl_method = "siamese_unet"
|
| 899 |
except Exception:
|
| 900 |
pass
|
| 901 |
|
| 902 |
-
|
| 903 |
-
|
| 904 |
-
|
| 905 |
-
# --- Build confidence map ---
|
| 906 |
-
conf_map = _build_confidence_map_from_channels(
|
| 907 |
-
img1, img2, dl_score=dl_score if dl_method != "none" else None)
|
| 908 |
-
|
| 909 |
-
# --- Weighted fusion ---
|
| 910 |
-
dl_w = 0.7 if dl_method != "none" else 0.0
|
| 911 |
-
cl_w = 1.0 - dl_w
|
| 912 |
|
| 913 |
-
|
| 914 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 915 |
|
| 916 |
-
|
| 917 |
-
|
| 918 |
-
|
| 919 |
-
fused = fused * (0.6 + 0.4 * conf_boost)
|
| 920 |
|
| 921 |
-
fused_thr = max(80, int(128 - (sensitivity - 0.5) * 60))
|
| 922 |
-
_, final_mask = cv2.threshold(fused.astype(np.uint8), fused_thr, 255, cv2.THRESH_BINARY)
|
| 923 |
-
final_mask = _clean_mask(final_mask, sensitivity=sensitivity)
|
| 924 |
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
"sensitivity": float(sensitivity),
|
| 930 |
-
"dl_changed_px": int(np.sum(dl_mask > 127)),
|
| 931 |
-
"classical_changed_px": int(np.sum(classical_mask > 127)),
|
| 932 |
-
"final_changed_px": int(np.sum(final_mask > 127)),
|
| 933 |
-
}
|
| 934 |
-
return final_mask, debug
|
| 935 |
|
| 936 |
|
| 937 |
# ---------------------------------------------------------------------------
|
|
@@ -981,7 +999,7 @@ def _clean_mask(mask, sensitivity=0.5, border_margin=12):
|
|
| 981 |
filled = cv2.dilate(filled, k_break, iterations=1)
|
| 982 |
|
| 983 |
# 7. Component-level filtering: remove tiny survivors and elongated noise
|
| 984 |
-
min_component_px = max(
|
| 985 |
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(filled, connectivity=8)
|
| 986 |
clean = np.zeros_like(filled)
|
| 987 |
for i in range(1, num_labels):
|
|
@@ -991,11 +1009,13 @@ def _clean_mask(mask, sensitivity=0.5, border_margin=12):
|
|
| 991 |
cw = stats[i, cv2.CC_STAT_WIDTH]
|
| 992 |
ch = stats[i, cv2.CC_STAT_HEIGHT]
|
| 993 |
bbox_area = max(cw * ch, 1)
|
|
|
|
| 994 |
perimeter_approx = 2 * (cw + ch)
|
| 995 |
-
# Circularity: thin elongated noise has very high perimeter^2/area
|
| 996 |
circularity = (perimeter_approx ** 2) / (bbox_area + 1e-8)
|
| 997 |
if circularity > 80 and area < min_component_px * 3:
|
| 998 |
continue
|
|
|
|
|
|
|
| 999 |
clean[labels == i] = 255
|
| 1000 |
|
| 1001 |
return clean
|
|
@@ -2442,21 +2462,29 @@ def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
|
|
| 2442 |
after_array = preprocess_image(after_pil)
|
| 2443 |
|
| 2444 |
registration_ok = False
|
|
|
|
| 2445 |
if enable_registration:
|
| 2446 |
-
before_array, after_array, registration_ok = register_images(
|
|
|
|
| 2447 |
if enable_normalization:
|
| 2448 |
before_array, after_array = normalize_radiometry(before_array, after_array)
|
| 2449 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2450 |
if method == "AI-Based Deep Learning":
|
| 2451 |
change_mask, threshold_debug = ai_deep_learning_method(
|
| 2452 |
-
before_array, after_array,
|
|
|
|
|
|
|
| 2453 |
)
|
| 2454 |
elif method == "Image Difference":
|
| 2455 |
change_mask, threshold_debug = image_difference_method(
|
| 2456 |
-
before_array, after_array, sensitivity=detection_sensitivity
|
| 2457 |
-
)
|
| 2458 |
elif method == "Feature-Based":
|
| 2459 |
-
change_mask = feature_based_method(
|
|
|
|
| 2460 |
threshold_debug = {
|
| 2461 |
"method": "Feature-Based",
|
| 2462 |
"threshold_used": None,
|
|
@@ -2465,51 +2493,56 @@ def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
|
|
| 2465 |
}
|
| 2466 |
elif method == "Hybrid AI":
|
| 2467 |
change_mask, threshold_debug = hybrid_ai_method(
|
| 2468 |
-
before_array, after_array,
|
|
|
|
|
|
|
| 2469 |
)
|
| 2470 |
else:
|
| 2471 |
change_mask, threshold_debug = hybrid_method(
|
| 2472 |
-
before_array, after_array,
|
|
|
|
|
|
|
| 2473 |
)
|
| 2474 |
|
| 2475 |
-
# --- Adaptive fallback for empty/sparse masks ---
|
| 2476 |
-
# In some scenes, ORB/ECC registration + fused thresholding can produce an overly
|
| 2477 |
-
# sparse binary mask (leading to 0 detected regions). If that happens, fall back
|
| 2478 |
-
# to the more stable Image Difference mask.
|
| 2479 |
total_pixels = int(change_mask.shape[0] * change_mask.shape[1])
|
| 2480 |
-
changed_pixels_ratio =
|
|
|
|
|
|
|
| 2481 |
|
| 2482 |
-
|
| 2483 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2484 |
diff_mask, diff_debug = image_difference_method(
|
| 2485 |
-
before_array, after_array, sensitivity=detection_sensitivity
|
|
|
|
|
|
|
|
|
|
| 2486 |
)
|
| 2487 |
-
|
| 2488 |
-
# Only switch if the diff mask clearly contains more signal.
|
| 2489 |
-
if diff_ratio > max(0.005, changed_pixels_ratio * 3.0):
|
| 2490 |
change_mask = diff_mask
|
| 2491 |
-
|
| 2492 |
threshold_debug = {
|
| 2493 |
"method": f"{method} (fallback->Image Difference)",
|
| 2494 |
"fallback_used": True,
|
| 2495 |
-
"ai_hybrid_changed_ratio": changed_pixels_ratio,
|
| 2496 |
-
"diff_changed_ratio": diff_ratio,
|
| 2497 |
"diff_debug": diff_debug,
|
| 2498 |
"sensitivity": float(detection_sensitivity),
|
| 2499 |
}
|
| 2500 |
|
| 2501 |
-
change_regions = analyze_change_regions(
|
| 2502 |
-
change_mask,
|
| 2503 |
-
after_array,
|
| 2504 |
-
min_area=min_region_area,
|
| 2505 |
-
before_img=before_array,
|
| 2506 |
-
registration_ok=registration_ok,
|
| 2507 |
-
)
|
| 2508 |
-
|
| 2509 |
total_pixels = int(change_mask.shape[0] * change_mask.shape[1])
|
| 2510 |
result_image = visualize_changes(
|
| 2511 |
before_array, after_array, change_mask,
|
| 2512 |
-
regions=change_regions, total_pixels=total_pixels
|
| 2513 |
)
|
| 2514 |
changed_pixels = int(np.sum(change_mask > 127))
|
| 2515 |
change_pct = (changed_pixels / total_pixels * 100.0) if total_pixels else 0.0
|
|
@@ -2522,12 +2555,14 @@ def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
|
|
| 2522 |
"image_width": change_mask.shape[1],
|
| 2523 |
"image_height": change_mask.shape[0],
|
| 2524 |
"threshold_debug": threshold_debug,
|
|
|
|
| 2525 |
"params": {
|
| 2526 |
"detection_sensitivity": float(detection_sensitivity),
|
| 2527 |
"min_region_area": min_region_area,
|
| 2528 |
"enable_registration": bool(enable_registration),
|
| 2529 |
"enable_normalization": bool(enable_normalization),
|
| 2530 |
"registration_ok": bool(registration_ok),
|
|
|
|
| 2531 |
},
|
| 2532 |
}
|
| 2533 |
|
|
|
|
| 38 |
return img.astype(np.float32) / 255.0
|
| 39 |
|
| 40 |
|
| 41 |
+
def preprocess_image(image, max_size=1600):
|
| 42 |
+
"""Preprocess image: convert to RGB, limit size, light Gaussian denoise."""
|
| 43 |
img_array = np.array(image)
|
| 44 |
img_array = _ensure_rgb_uint8(img_array)
|
| 45 |
|
|
|
|
| 49 |
new_w, new_h = max(1, int(width * scale)), max(1, int(height * scale))
|
| 50 |
img_array = cv2.resize(img_array, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
| 51 |
|
|
|
|
| 52 |
img_array = cv2.GaussianBlur(img_array, (5, 5), 0)
|
| 53 |
+
gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
|
| 54 |
+
lap_var = float(cv2.Laplacian(gray, cv2.CV_64F).var())
|
| 55 |
+
if lap_var < 80.0:
|
| 56 |
+
img_array = cv2.bilateralFilter(img_array, 5, 50, 50)
|
| 57 |
return img_array
|
| 58 |
|
| 59 |
|
|
|
|
| 123 |
return best_H, best_ir
|
| 124 |
|
| 125 |
|
| 126 |
+
def _alignment_ncc(img1, img2):
|
| 127 |
+
"""Global normalized cross-correlation between two RGB images."""
|
| 128 |
+
g1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY).astype(np.float32).ravel()
|
| 129 |
+
g2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY).astype(np.float32).ravel()
|
| 130 |
+
if g1.size != g2.size or g1.size < 64:
|
| 131 |
+
return 0.0
|
| 132 |
+
c = np.corrcoef(g1, g2)[0, 1]
|
| 133 |
+
return float(c) if np.isfinite(c) else 0.0
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def _phase_correlation_translate(img2, gray1, gray2):
|
| 137 |
+
"""Shift img2 by phase-correlation offset (same-scale screenshot pairs)."""
|
| 138 |
+
try:
|
| 139 |
+
shift, _ = cv2.phaseCorrelate(gray1.astype(np.float32), gray2.astype(np.float32))
|
| 140 |
+
dx, dy = float(shift[0]), float(shift[1])
|
| 141 |
+
if abs(dx) < 0.5 and abs(dy) < 0.5:
|
| 142 |
+
return img2
|
| 143 |
+
h, w = img2.shape[:2]
|
| 144 |
+
M = np.float32([[1, 0, dx], [0, 1, dy]])
|
| 145 |
+
return cv2.warpAffine(img2, M, (w, h), borderMode=cv2.BORDER_REFLECT)
|
| 146 |
+
except Exception:
|
| 147 |
+
return img2
|
| 148 |
+
|
| 149 |
+
|
| 150 |
def register_images(img1, img2, max_features=3000):
|
| 151 |
"""
|
| 152 |
+
Multi-stage alignment with quality metrics.
|
| 153 |
+
Returns (img1, img2_aligned, registration_ok, reg_meta).
|
|
|
|
|
|
|
|
|
|
| 154 |
"""
|
| 155 |
h, w = img1.shape[:2]
|
| 156 |
+
reg_meta = {
|
| 157 |
+
"method": "none",
|
| 158 |
+
"inlier_ratio": 0.0,
|
| 159 |
+
"ncc": 0.0,
|
| 160 |
+
"homography_used": False,
|
| 161 |
+
}
|
| 162 |
|
| 163 |
if img1.shape[:2] != img2.shape[:2]:
|
| 164 |
img2 = cv2.resize(img2, (w, h), interpolation=cv2.INTER_LINEAR)
|
|
|
|
| 166 |
gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY)
|
| 167 |
gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY)
|
| 168 |
|
| 169 |
+
img2 = _phase_correlation_translate(img2, gray1, gray2)
|
| 170 |
+
gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY)
|
| 171 |
|
| 172 |
+
match_method = "sift"
|
| 173 |
+
H, ir = _match_features_sift(gray1, gray2)
|
| 174 |
if H is None or ir < 0.25:
|
| 175 |
H_orb, ir_orb = _match_features_orb(gray1, gray2, max_features)
|
| 176 |
if ir_orb > ir:
|
| 177 |
H, ir = H_orb, ir_orb
|
| 178 |
+
match_method = "orb"
|
| 179 |
|
| 180 |
+
reg_meta["inlier_ratio"] = float(ir)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
|
| 182 |
+
if H is not None and ir >= 0.35:
|
| 183 |
+
img2_warped = cv2.warpPerspective(img2, H, (w, h), borderMode=cv2.BORDER_REFLECT)
|
| 184 |
+
img2_refined = _refine_ecc(img1, img2_warped)
|
| 185 |
+
ncc = _alignment_ncc(img1, img2_refined)
|
| 186 |
+
reg_meta.update({
|
| 187 |
+
"method": match_method,
|
| 188 |
+
"ncc": ncc,
|
| 189 |
+
"homography_used": True,
|
| 190 |
+
})
|
| 191 |
+
if ncc >= 0.55:
|
| 192 |
+
return img1, img2_refined, True, reg_meta
|
| 193 |
+
reg_meta["method"] = f"{match_method}_rejected"
|
| 194 |
+
return img1, img2, False, reg_meta
|
| 195 |
+
|
| 196 |
+
img1_ecc, img2_ecc, ok, ecc_meta = _register_images_ecc_multiscale(img1, img2)
|
| 197 |
+
reg_meta.update(ecc_meta)
|
| 198 |
+
return img1_ecc, img2_ecc, ok, reg_meta
|
| 199 |
|
| 200 |
|
| 201 |
def _refine_ecc(img1, img2_initial):
|
|
|
|
| 281 |
g_aligned = cv2.cvtColor(aligned, cv2.COLOR_RGB2GRAY).astype(np.float32)
|
| 282 |
g_ref = gray1.astype(np.float32)
|
| 283 |
ncc = float(np.corrcoef(g_ref.ravel(), g_aligned.ravel())[0, 1])
|
| 284 |
+
if not np.isfinite(ncc):
|
| 285 |
+
ncc = 0.0
|
| 286 |
+
meta = {"method": "ecc_multiscale", "inlier_ratio": 0.0, "ncc": ncc, "homography_used": False}
|
| 287 |
+
return img1, aligned, bool(ncc >= 0.50), meta
|
| 288 |
except Exception:
|
| 289 |
+
return img1, img2, False, {
|
| 290 |
+
"method": "ecc_failed", "inlier_ratio": 0.0, "ncc": 0.0, "homography_used": False,
|
| 291 |
+
}
|
| 292 |
|
| 293 |
|
| 294 |
# ---------------------------------------------------------------------------
|
|
|
|
| 296 |
# ---------------------------------------------------------------------------
|
| 297 |
|
| 298 |
def normalize_radiometry(img1, img2):
|
| 299 |
+
"""Match after image radiometry to before; symmetric CLAHE on L channel."""
|
| 300 |
lab1 = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 301 |
lab2 = cv2.cvtColor(img2, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 302 |
|
|
|
|
| 307 |
if std2 > 1e-6:
|
| 308 |
result[:, :, ch] = (lab2[:, :, ch] - mean2) * (std1 / std2) + mean1
|
| 309 |
|
|
|
|
|
|
|
|
|
|
| 310 |
clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
|
| 311 |
+
lab1_u = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB)
|
| 312 |
+
lab2_u = np.clip(result, 0, 255).astype(np.uint8)
|
| 313 |
+
lab1_u[:, :, 0] = clahe.apply(lab1_u[:, :, 0])
|
| 314 |
+
lab2_u[:, :, 0] = clahe.apply(lab2_u[:, :, 0])
|
| 315 |
|
| 316 |
+
return cv2.cvtColor(lab1_u, cv2.COLOR_LAB2RGB), cv2.cvtColor(lab2_u, cv2.COLOR_LAB2RGB)
|
|
|
|
|
|
|
| 317 |
|
| 318 |
|
| 319 |
# ---------------------------------------------------------------------------
|
|
|
|
| 668 |
return signal / noise
|
| 669 |
|
| 670 |
|
| 671 |
+
def _compute_classical_score_map(img1, img2, registration_ok=True):
|
| 672 |
+
"""SNR-weighted classical change score in [0,1] before binary threshold."""
|
|
|
|
|
|
|
|
|
|
| 673 |
if img1.shape != img2.shape:
|
| 674 |
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 675 |
|
|
|
|
| 676 |
lab1 = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 677 |
lab2 = cv2.cvtColor(img2, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 678 |
|
|
|
|
| 679 |
color_maps = []
|
| 680 |
+
for scale in (1, 2, 4):
|
| 681 |
if scale > 1:
|
| 682 |
s1 = cv2.resize(lab1, (lab1.shape[1] // scale, lab1.shape[0] // scale))
|
| 683 |
s2 = cv2.resize(lab2, (lab2.shape[1] // scale, lab2.shape[0] // scale))
|
|
|
|
| 694 |
color_change = np.mean(color_maps, axis=0)
|
| 695 |
color_change = color_change / (color_change.max() + 1e-8)
|
| 696 |
|
|
|
|
| 697 |
ssim_change = compute_ssim_change_map(img1, img2)
|
| 698 |
ssim_change = ssim_change / (ssim_change.max() + 1e-8)
|
|
|
|
|
|
|
| 699 |
texture_change = compute_texture_change(img1, img2)
|
| 700 |
texture_change = texture_change / (texture_change.max() + 1e-8)
|
|
|
|
|
|
|
| 701 |
edge_change = compute_edge_change(img1, img2)
|
|
|
|
|
|
|
| 702 |
cva_change = compute_cva(img1, img2)
|
| 703 |
|
| 704 |
+
if not registration_ok:
|
| 705 |
+
ssim_change = ssim_change * 0.45
|
| 706 |
+
edge_change = edge_change * 0.45
|
| 707 |
+
|
| 708 |
channels = [color_change, ssim_change, texture_change, edge_change, cva_change]
|
| 709 |
weights = [_snr_weight(ch) for ch in channels]
|
| 710 |
total_w = sum(weights) + 1e-8
|
|
|
|
| 714 |
for ch, w in zip(channels, weights):
|
| 715 |
fused += w * ch.astype(np.float64)
|
| 716 |
|
|
|
|
| 717 |
veg_suppression = compute_combined_vegetation_suppression(img1, img2)
|
| 718 |
shadow_suppression = compute_shadow_suppression(img1, img2)
|
| 719 |
fused = fused * veg_suppression.astype(np.float64) * shadow_suppression.astype(np.float64)
|
| 720 |
|
|
|
|
| 721 |
p995 = float(np.quantile(fused, 0.995))
|
| 722 |
if p995 <= 1e-8:
|
| 723 |
p995 = float(fused.max() + 1e-8)
|
| 724 |
fused_norm = np.clip(fused / (p995 + 1e-8), 0.0, 1.0)
|
| 725 |
+
fused_norm = np.power(fused_norm, 0.85)
|
| 726 |
+
return cv2.GaussianBlur(fused_norm.astype(np.float32), (5, 5), 0), weights
|
| 727 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 728 |
|
| 729 |
+
def fuse_dl_and_classical(dl_score, classical_score, img1, img2, sensitivity=0.5):
|
| 730 |
+
"""
|
| 731 |
+
Confidence-gated fusion (not union): DL drives structure; classical + ExG for vegetation.
|
| 732 |
+
Returns (mask, final_score_map, debug).
|
| 733 |
+
"""
|
| 734 |
sens = float(np.clip(sensitivity, 0.0, 1.0))
|
| 735 |
+
h, w = classical_score.shape
|
| 736 |
+
if dl_score is None or dl_score.shape != classical_score.shape:
|
| 737 |
+
dl_score = np.zeros((h, w), dtype=np.float32)
|
| 738 |
|
| 739 |
+
q = float(np.clip(0.96 - (sens - 0.5) * 0.04, 0.92, 0.98))
|
| 740 |
+
T_cl = float(np.quantile(classical_score, q))
|
| 741 |
|
| 742 |
+
final_score = 0.65 * dl_score.astype(np.float32) + 0.35 * classical_score
|
| 743 |
+
|
| 744 |
+
med_dl, med_cl = 0.35, T_cl * 0.7
|
| 745 |
+
both_agree = (dl_score >= med_dl) & (classical_score >= med_cl)
|
| 746 |
+
final_score = np.where(both_agree, np.maximum(dl_score, classical_score), final_score)
|
| 747 |
+
|
| 748 |
+
exg1 = compute_excess_green(img1)
|
| 749 |
+
exg2 = compute_excess_green(img2)
|
| 750 |
+
delta_exg = np.abs(exg2 - exg1)
|
| 751 |
+
veg_boost = (delta_exg > 0.04) & (classical_score >= T_cl * 0.8)
|
| 752 |
+
final_score = np.where(veg_boost, np.maximum(final_score, classical_score), final_score)
|
| 753 |
+
|
| 754 |
+
fused_thr = 0.45 + (1.0 - sens) * 0.15
|
| 755 |
+
change_mask = (final_score >= fused_thr).astype(np.uint8) * 255
|
| 756 |
change_mask = _clean_mask(change_mask, sensitivity=sens)
|
| 757 |
|
| 758 |
+
debug = {
|
| 759 |
+
"fusion": "confidence_gated",
|
| 760 |
+
"T_dl": 0.40 + (1.0 - sens) * 0.25,
|
| 761 |
+
"T_cl_percentile_q": q,
|
| 762 |
+
"T_cl_score": T_cl,
|
| 763 |
+
"fused_threshold": fused_thr,
|
| 764 |
+
"dl_changed_px": int(np.sum(dl_score >= med_dl)),
|
| 765 |
+
"classical_changed_px": int(np.sum(classical_score >= T_cl)),
|
| 766 |
+
"fused_changed_px": int(np.sum(change_mask > 127)),
|
| 767 |
+
}
|
| 768 |
+
return change_mask, final_score, debug
|
| 769 |
+
|
| 770 |
+
|
| 771 |
+
def _ai_fusion_core(img1, img2, sensitivity=0.5, registration_ok=True):
|
| 772 |
+
"""Classical-only path: score map + threshold. Returns (mask, score_map, debug)."""
|
| 773 |
+
classical_score, weights = _compute_classical_score_map(
|
| 774 |
+
img1, img2, registration_ok=registration_ok)
|
| 775 |
+
|
| 776 |
+
sens = float(np.clip(sensitivity, 0.0, 1.0))
|
| 777 |
+
q = float(np.clip(0.96 - (sens - 0.5) * 0.04, 0.92, 0.98))
|
| 778 |
+
thr_score = float(np.quantile(classical_score, q))
|
| 779 |
+
change_mask = (classical_score >= thr_score).astype(np.uint8) * 255
|
| 780 |
+
change_mask = _clean_mask(change_mask, sensitivity=sens)
|
| 781 |
|
| 782 |
debug = {
|
| 783 |
"method": "AI-Core",
|
| 784 |
"threshold_used": int(thr_score * 255),
|
| 785 |
"threshold_percentile_q": q,
|
| 786 |
"threshold_score": thr_score,
|
|
|
|
|
|
|
|
|
|
| 787 |
"sensitivity": float(sensitivity),
|
| 788 |
"channel_weights": {
|
| 789 |
"color": round(weights[0], 4),
|
|
|
|
| 793 |
"cva": round(weights[4], 4),
|
| 794 |
},
|
| 795 |
}
|
| 796 |
+
return change_mask, classical_score, debug
|
| 797 |
|
| 798 |
|
| 799 |
+
def ai_deep_learning_method(img1, img2, sensitivity=0.5, registration_ok=True):
|
| 800 |
+
"""AdaptFormer + confidence-gated classical fusion (no blind union)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 801 |
from .model_inference import is_model_available, predict_change_mask
|
|
|
|
|
|
|
| 802 |
|
| 803 |
+
dl_score = None
|
| 804 |
model_ok = False
|
| 805 |
+
T_dl = 0.40 + (1.0 - float(np.clip(sensitivity, 0, 1))) * 0.25
|
| 806 |
|
| 807 |
if is_model_available():
|
|
|
|
| 808 |
try:
|
| 809 |
+
_, dl_score = predict_change_mask(img1, img2, threshold=2.0)
|
| 810 |
+
model_ok = dl_score is not None
|
|
|
|
|
|
|
| 811 |
except Exception as e:
|
| 812 |
_log.warning("AdaptFormer inference failed: %s", e)
|
| 813 |
|
| 814 |
+
classical_score, _ = _compute_classical_score_map(
|
| 815 |
+
img1, img2, registration_ok=registration_ok)
|
| 816 |
|
| 817 |
+
if model_ok and dl_score is not None:
|
| 818 |
+
combined, _, fuse_debug = fuse_dl_and_classical(
|
| 819 |
+
dl_score, classical_score, img1, img2, sensitivity=sensitivity)
|
|
|
|
| 820 |
debug = {
|
| 821 |
+
"method": "AI-Based Deep Learning (AdaptFormer + gated fusion)",
|
| 822 |
"model": "adaptformer-levir-cd",
|
| 823 |
+
"threshold_used": int(T_dl * 255),
|
| 824 |
"sensitivity": float(sensitivity),
|
| 825 |
+
**fuse_debug,
|
|
|
|
|
|
|
| 826 |
}
|
| 827 |
return combined, debug
|
| 828 |
|
| 829 |
+
rule_mask, _, core_debug = _ai_fusion_core(
|
| 830 |
+
img1, img2, sensitivity=sensitivity, registration_ok=registration_ok)
|
| 831 |
debug = {
|
| 832 |
+
"method": "AI-Based Deep Learning (classical fallback)",
|
|
|
|
| 833 |
"sensitivity": float(sensitivity),
|
| 834 |
"core": core_debug,
|
| 835 |
}
|
| 836 |
return rule_mask, debug
|
| 837 |
|
| 838 |
|
| 839 |
+
def hybrid_method(img1, img2, sensitivity=0.5, registration_ok=True):
|
| 840 |
"""Hybrid: weighted fusion of all methods with confidence-based merging."""
|
| 841 |
if img1.shape != img2.shape:
|
| 842 |
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 843 |
|
| 844 |
diff_mask, diff_debug = image_difference_method(img1, img2, sensitivity=sensitivity)
|
| 845 |
+
feature_mask = feature_based_method(img1, img2, sensitivity=sensitivity)
|
| 846 |
+
ai_mask, ai_debug = ai_deep_learning_method(
|
| 847 |
+
img1, img2, sensitivity=sensitivity, registration_ok=registration_ok)
|
| 848 |
|
|
|
|
| 849 |
combined = (
|
| 850 |
0.2 * diff_mask.astype(np.float32) +
|
| 851 |
0.3 * feature_mask.astype(np.float32) +
|
| 852 |
0.5 * ai_mask.astype(np.float32)
|
| 853 |
)
|
| 854 |
|
| 855 |
+
base_thr = 110
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 856 |
sens = float(np.clip(sensitivity, 0.0, 1.0))
|
| 857 |
+
hybrid_thr = int(np.clip(base_thr + int((0.5 - sens) * 36), 70, 160))
|
| 858 |
_, final_mask = cv2.threshold(combined.astype(np.uint8), hybrid_thr, 255, cv2.THRESH_BINARY)
|
| 859 |
+
final_mask = _clean_mask(final_mask, sensitivity=sensitivity)
|
| 860 |
debug = {
|
| 861 |
"method": "Hybrid Approach",
|
| 862 |
"threshold_used": int(hybrid_thr),
|
|
|
|
| 901 |
return build_confidence_map(channels, weights)
|
| 902 |
|
| 903 |
|
| 904 |
+
def hybrid_ai_method(img1, img2, sensitivity=0.5, registration_ok=True):
|
| 905 |
+
"""Hybrid AI: same confidence-gated fusion as default AI path."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 906 |
if img1.shape != img2.shape:
|
| 907 |
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 908 |
|
|
|
|
| 909 |
from .model_inference import is_model_available, predict_change_mask
|
|
|
|
|
|
|
|
|
|
| 910 |
|
| 911 |
+
dl_score = None
|
| 912 |
+
dl_method = "none"
|
| 913 |
|
| 914 |
if is_model_available():
|
| 915 |
try:
|
| 916 |
+
_, dl_score = predict_change_mask(img1, img2, threshold=2.0)
|
| 917 |
dl_method = "adaptformer"
|
| 918 |
except Exception:
|
| 919 |
pass
|
| 920 |
|
| 921 |
if dl_method == "none":
|
| 922 |
try:
|
| 923 |
+
from .cd_models.change_model import has_siamese_weights, predict_siamese
|
| 924 |
+
if has_siamese_weights():
|
| 925 |
+
_, dl_score = predict_siamese(img1, img2, threshold=2.0)
|
| 926 |
dl_method = "siamese_unet"
|
| 927 |
except Exception:
|
| 928 |
pass
|
| 929 |
|
| 930 |
+
classical_score, _ = _compute_classical_score_map(
|
| 931 |
+
img1, img2, registration_ok=registration_ok)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 932 |
|
| 933 |
+
if dl_method != "none" and dl_score is not None:
|
| 934 |
+
final_mask, _, fuse_debug = fuse_dl_and_classical(
|
| 935 |
+
dl_score, classical_score, img1, img2, sensitivity=sensitivity)
|
| 936 |
+
debug = {
|
| 937 |
+
"method": f"Hybrid AI ({dl_method} + gated fusion)",
|
| 938 |
+
"dl_method": dl_method,
|
| 939 |
+
"sensitivity": float(sensitivity),
|
| 940 |
+
**fuse_debug,
|
| 941 |
+
}
|
| 942 |
+
return final_mask, debug
|
| 943 |
|
| 944 |
+
mask, _, core_debug = _ai_fusion_core(
|
| 945 |
+
img1, img2, sensitivity=sensitivity, registration_ok=registration_ok)
|
| 946 |
+
return mask, {"method": "Hybrid AI (classical fallback)", "core": core_debug}
|
|
|
|
| 947 |
|
|
|
|
|
|
|
|
|
|
| 948 |
|
| 949 |
+
ALIGNMENT_WARNING_MSG = (
|
| 950 |
+
"Images may differ in zoom/crop; use the same map location, zoom level, and crop "
|
| 951 |
+
"for before and after screenshots."
|
| 952 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 953 |
|
| 954 |
|
| 955 |
# ---------------------------------------------------------------------------
|
|
|
|
| 999 |
filled = cv2.dilate(filled, k_break, iterations=1)
|
| 1000 |
|
| 1001 |
# 7. Component-level filtering: remove tiny survivors and elongated noise
|
| 1002 |
+
min_component_px = max(200, int(h * w * 0.00003))
|
| 1003 |
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(filled, connectivity=8)
|
| 1004 |
clean = np.zeros_like(filled)
|
| 1005 |
for i in range(1, num_labels):
|
|
|
|
| 1009 |
cw = stats[i, cv2.CC_STAT_WIDTH]
|
| 1010 |
ch = stats[i, cv2.CC_STAT_HEIGHT]
|
| 1011 |
bbox_area = max(cw * ch, 1)
|
| 1012 |
+
aspect = max(cw, ch) / (min(cw, ch) + 1e-8)
|
| 1013 |
perimeter_approx = 2 * (cw + ch)
|
|
|
|
| 1014 |
circularity = (perimeter_approx ** 2) / (bbox_area + 1e-8)
|
| 1015 |
if circularity > 80 and area < min_component_px * 3:
|
| 1016 |
continue
|
| 1017 |
+
if aspect > 12 and area < min_component_px * 2:
|
| 1018 |
+
continue
|
| 1019 |
clean[labels == i] = 255
|
| 1020 |
|
| 1021 |
return clean
|
|
|
|
| 2462 |
after_array = preprocess_image(after_pil)
|
| 2463 |
|
| 2464 |
registration_ok = False
|
| 2465 |
+
reg_meta = {}
|
| 2466 |
if enable_registration:
|
| 2467 |
+
before_array, after_array, registration_ok, reg_meta = register_images(
|
| 2468 |
+
before_array, after_array)
|
| 2469 |
if enable_normalization:
|
| 2470 |
before_array, after_array = normalize_radiometry(before_array, after_array)
|
| 2471 |
|
| 2472 |
+
alignment_warning = None
|
| 2473 |
+
if enable_registration and not registration_ok:
|
| 2474 |
+
alignment_warning = ALIGNMENT_WARNING_MSG
|
| 2475 |
+
|
| 2476 |
if method == "AI-Based Deep Learning":
|
| 2477 |
change_mask, threshold_debug = ai_deep_learning_method(
|
| 2478 |
+
before_array, after_array,
|
| 2479 |
+
sensitivity=detection_sensitivity,
|
| 2480 |
+
registration_ok=registration_ok,
|
| 2481 |
)
|
| 2482 |
elif method == "Image Difference":
|
| 2483 |
change_mask, threshold_debug = image_difference_method(
|
| 2484 |
+
before_array, after_array, sensitivity=detection_sensitivity)
|
|
|
|
| 2485 |
elif method == "Feature-Based":
|
| 2486 |
+
change_mask = feature_based_method(
|
| 2487 |
+
before_array, after_array, sensitivity=detection_sensitivity)
|
| 2488 |
threshold_debug = {
|
| 2489 |
"method": "Feature-Based",
|
| 2490 |
"threshold_used": None,
|
|
|
|
| 2493 |
}
|
| 2494 |
elif method == "Hybrid AI":
|
| 2495 |
change_mask, threshold_debug = hybrid_ai_method(
|
| 2496 |
+
before_array, after_array,
|
| 2497 |
+
sensitivity=detection_sensitivity,
|
| 2498 |
+
registration_ok=registration_ok,
|
| 2499 |
)
|
| 2500 |
else:
|
| 2501 |
change_mask, threshold_debug = hybrid_method(
|
| 2502 |
+
before_array, after_array,
|
| 2503 |
+
sensitivity=detection_sensitivity,
|
| 2504 |
+
registration_ok=registration_ok,
|
| 2505 |
)
|
| 2506 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2507 |
total_pixels = int(change_mask.shape[0] * change_mask.shape[1])
|
| 2508 |
+
changed_pixels_ratio = (
|
| 2509 |
+
float(np.sum(change_mask > 127)) / float(total_pixels) if total_pixels else 0.0
|
| 2510 |
+
)
|
| 2511 |
|
| 2512 |
+
change_regions = analyze_change_regions(
|
| 2513 |
+
change_mask,
|
| 2514 |
+
after_array,
|
| 2515 |
+
min_area=min_region_area,
|
| 2516 |
+
before_img=before_array,
|
| 2517 |
+
registration_ok=registration_ok,
|
| 2518 |
+
)
|
| 2519 |
+
|
| 2520 |
+
if (
|
| 2521 |
+
method in ("AI-Based Deep Learning", "Hybrid Approach", "Hybrid AI")
|
| 2522 |
+
and len(change_regions) == 0
|
| 2523 |
+
and registration_ok
|
| 2524 |
+
and changed_pixels_ratio == 0.0
|
| 2525 |
+
):
|
| 2526 |
diff_mask, diff_debug = image_difference_method(
|
| 2527 |
+
before_array, after_array, sensitivity=detection_sensitivity)
|
| 2528 |
+
diff_regions = analyze_change_regions(
|
| 2529 |
+
diff_mask, after_array, min_area=min_region_area,
|
| 2530 |
+
before_img=before_array, registration_ok=registration_ok,
|
| 2531 |
)
|
| 2532 |
+
if len(diff_regions) > 0:
|
|
|
|
|
|
|
| 2533 |
change_mask = diff_mask
|
| 2534 |
+
change_regions = diff_regions
|
| 2535 |
threshold_debug = {
|
| 2536 |
"method": f"{method} (fallback->Image Difference)",
|
| 2537 |
"fallback_used": True,
|
|
|
|
|
|
|
| 2538 |
"diff_debug": diff_debug,
|
| 2539 |
"sensitivity": float(detection_sensitivity),
|
| 2540 |
}
|
| 2541 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2542 |
total_pixels = int(change_mask.shape[0] * change_mask.shape[1])
|
| 2543 |
result_image = visualize_changes(
|
| 2544 |
before_array, after_array, change_mask,
|
| 2545 |
+
regions=change_regions, total_pixels=total_pixels,
|
| 2546 |
)
|
| 2547 |
changed_pixels = int(np.sum(change_mask > 127))
|
| 2548 |
change_pct = (changed_pixels / total_pixels * 100.0) if total_pixels else 0.0
|
|
|
|
| 2555 |
"image_width": change_mask.shape[1],
|
| 2556 |
"image_height": change_mask.shape[0],
|
| 2557 |
"threshold_debug": threshold_debug,
|
| 2558 |
+
"alignment_warning": alignment_warning,
|
| 2559 |
"params": {
|
| 2560 |
"detection_sensitivity": float(detection_sensitivity),
|
| 2561 |
"min_region_area": min_region_area,
|
| 2562 |
"enable_registration": bool(enable_registration),
|
| 2563 |
"enable_normalization": bool(enable_normalization),
|
| 2564 |
"registration_ok": bool(registration_ok),
|
| 2565 |
+
"registration": reg_meta,
|
| 2566 |
},
|
| 2567 |
}
|
| 2568 |
|
app/main.py
CHANGED
|
@@ -82,6 +82,11 @@ def health():
|
|
| 82 |
@app.on_event("startup")
|
| 83 |
def log_startup():
|
| 84 |
logger.info("FastAPI startup event completed")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
# Mount static files
|
| 87 |
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
|
@@ -395,6 +400,8 @@ async def detect(
|
|
| 395 |
"changePercentage": change_pct,
|
| 396 |
"thresholdDebug": stats.get("threshold_debug", {}),
|
| 397 |
"params": stats.get("params", {}),
|
|
|
|
|
|
|
| 398 |
},
|
| 399 |
"regions": regions_serializable,
|
| 400 |
"overlayBase64Png": overlay_b64,
|
|
|
|
| 82 |
@app.on_event("startup")
|
| 83 |
def log_startup():
|
| 84 |
logger.info("FastAPI startup event completed")
|
| 85 |
+
try:
|
| 86 |
+
from .model_inference import preload_model
|
| 87 |
+
preload_model()
|
| 88 |
+
except Exception as exc:
|
| 89 |
+
logger.warning("Model preload at startup failed: %s", exc)
|
| 90 |
|
| 91 |
# Mount static files
|
| 92 |
STATIC_DIR = Path(__file__).resolve().parent.parent / "static"
|
|
|
|
| 400 |
"changePercentage": change_pct,
|
| 401 |
"thresholdDebug": stats.get("threshold_debug", {}),
|
| 402 |
"params": stats.get("params", {}),
|
| 403 |
+
"alignmentWarning": stats.get("alignment_warning"),
|
| 404 |
+
"registrationOk": stats.get("params", {}).get("registration_ok"),
|
| 405 |
},
|
| 406 |
"regions": regions_serializable,
|
| 407 |
"overlayBase64Png": overlay_b64,
|
app/model_inference.py
CHANGED
|
@@ -21,6 +21,7 @@ _DEVICE = None
|
|
| 21 |
_MODEL_ID = "deepang/adaptformer-LEVIR-CD"
|
| 22 |
_TILE_SIZE = 256 # LEVIR-CD native patch size
|
| 23 |
_AVAILABLE = None
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
def _try_import():
|
|
@@ -32,20 +33,12 @@ def _try_import():
|
|
| 32 |
return None, None, None
|
| 33 |
|
| 34 |
|
| 35 |
-
def is_model_available():
|
| 36 |
-
"""Check if torch and transformers are installed."""
|
| 37 |
-
global _AVAILABLE
|
| 38 |
-
if _AVAILABLE is not None:
|
| 39 |
-
return _AVAILABLE
|
| 40 |
-
torch, _, _ = _try_import()
|
| 41 |
-
_AVAILABLE = torch is not None
|
| 42 |
-
return _AVAILABLE
|
| 43 |
-
|
| 44 |
-
|
| 45 |
def _load_model():
|
| 46 |
-
global _MODEL, _PROCESSOR, _DEVICE
|
| 47 |
if _MODEL is not None:
|
| 48 |
return _MODEL, _PROCESSOR
|
|
|
|
|
|
|
| 49 |
|
| 50 |
torch, AutoImageProcessor, AutoModel = _try_import()
|
| 51 |
if torch is None:
|
|
@@ -55,24 +48,53 @@ def _load_model():
|
|
| 55 |
|
| 56 |
cache_dir = os.environ.get("HF_HOME", None)
|
| 57 |
logger.info("Loading AdaptFormer from %s ...", _MODEL_ID)
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
return _MODEL, _PROCESSOR
|
| 66 |
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
def predict_change_mask(img1, img2, threshold=0.5):
|
| 69 |
"""
|
| 70 |
Run AdaptFormer inference on two RGB numpy arrays (H, W, 3).
|
| 71 |
-
Images are split into overlapping 256x256 tiles (matching LEVIR-CD
|
| 72 |
-
training resolution), predicted individually, and stitched back into
|
| 73 |
-
a full-resolution binary mask.
|
| 74 |
-
|
| 75 |
Returns (uint8 mask [0 or 255], float32 score map [0-1]).
|
|
|
|
| 76 |
"""
|
| 77 |
torch, _, _ = _try_import()
|
| 78 |
model, processor = _load_model()
|
|
@@ -83,8 +105,8 @@ def predict_change_mask(img1, img2, threshold=0.5):
|
|
| 83 |
|
| 84 |
h, w = img1.shape[:2]
|
| 85 |
tile = _TILE_SIZE
|
| 86 |
-
overlap = tile // 4
|
| 87 |
-
stride = tile - overlap
|
| 88 |
|
| 89 |
pad_h = (tile - h % tile) % tile
|
| 90 |
pad_w = (tile - w % tile) % tile
|
|
@@ -96,7 +118,6 @@ def predict_change_mask(img1, img2, threshold=0.5):
|
|
| 96 |
score_sum = np.zeros((ph, pw), dtype=np.float32)
|
| 97 |
count = np.zeros((ph, pw), dtype=np.float32)
|
| 98 |
|
| 99 |
-
# Blending weight: raised-cosine window avoids hard tile boundary seams
|
| 100 |
ramp = np.linspace(0, 1, overlap)
|
| 101 |
flat = np.ones(tile - 2 * overlap)
|
| 102 |
profile = np.concatenate([ramp, flat, ramp[::-1]])
|
|
|
|
| 21 |
_MODEL_ID = "deepang/adaptformer-LEVIR-CD"
|
| 22 |
_TILE_SIZE = 256 # LEVIR-CD native patch size
|
| 23 |
_AVAILABLE = None
|
| 24 |
+
_LOAD_FAILED = False
|
| 25 |
|
| 26 |
|
| 27 |
def _try_import():
|
|
|
|
| 33 |
return None, None, None
|
| 34 |
|
| 35 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
def _load_model():
|
| 37 |
+
global _MODEL, _PROCESSOR, _DEVICE, _AVAILABLE, _LOAD_FAILED
|
| 38 |
if _MODEL is not None:
|
| 39 |
return _MODEL, _PROCESSOR
|
| 40 |
+
if _LOAD_FAILED:
|
| 41 |
+
raise RuntimeError("AdaptFormer load previously failed")
|
| 42 |
|
| 43 |
torch, AutoImageProcessor, AutoModel = _try_import()
|
| 44 |
if torch is None:
|
|
|
|
| 48 |
|
| 49 |
cache_dir = os.environ.get("HF_HOME", None)
|
| 50 |
logger.info("Loading AdaptFormer from %s ...", _MODEL_ID)
|
| 51 |
+
try:
|
| 52 |
+
_PROCESSOR = AutoImageProcessor.from_pretrained(
|
| 53 |
+
_MODEL_ID, cache_dir=cache_dir, trust_remote_code=True)
|
| 54 |
+
_MODEL = AutoModel.from_pretrained(
|
| 55 |
+
_MODEL_ID, cache_dir=cache_dir, trust_remote_code=True)
|
| 56 |
+
_MODEL.to(_DEVICE)
|
| 57 |
+
_MODEL.eval()
|
| 58 |
+
_AVAILABLE = True
|
| 59 |
+
logger.info("AdaptFormer loaded on %s", _DEVICE)
|
| 60 |
+
except Exception as exc:
|
| 61 |
+
_LOAD_FAILED = True
|
| 62 |
+
_AVAILABLE = False
|
| 63 |
+
logger.error("AdaptFormer load failed: %s", exc)
|
| 64 |
+
raise
|
| 65 |
return _MODEL, _PROCESSOR
|
| 66 |
|
| 67 |
|
| 68 |
+
def is_model_available():
|
| 69 |
+
"""True only if PyTorch is installed and the model loads successfully."""
|
| 70 |
+
global _AVAILABLE
|
| 71 |
+
if _AVAILABLE is not None:
|
| 72 |
+
return _AVAILABLE
|
| 73 |
+
if _LOAD_FAILED:
|
| 74 |
+
return False
|
| 75 |
+
try:
|
| 76 |
+
_load_model()
|
| 77 |
+
return True
|
| 78 |
+
except Exception:
|
| 79 |
+
return False
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
def preload_model():
|
| 83 |
+
"""Warm-load AdaptFormer at app startup (best-effort)."""
|
| 84 |
+
try:
|
| 85 |
+
_load_model()
|
| 86 |
+
logger.info("AdaptFormer preload complete")
|
| 87 |
+
return True
|
| 88 |
+
except Exception as exc:
|
| 89 |
+
logger.warning("AdaptFormer preload skipped: %s", exc)
|
| 90 |
+
return False
|
| 91 |
+
|
| 92 |
+
|
| 93 |
def predict_change_mask(img1, img2, threshold=0.5):
|
| 94 |
"""
|
| 95 |
Run AdaptFormer inference on two RGB numpy arrays (H, W, 3).
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
Returns (uint8 mask [0 or 255], float32 score map [0-1]).
|
| 97 |
+
Use threshold > 1.0 to obtain score map only (empty mask).
|
| 98 |
"""
|
| 99 |
torch, _, _ = _try_import()
|
| 100 |
model, processor = _load_model()
|
|
|
|
| 105 |
|
| 106 |
h, w = img1.shape[:2]
|
| 107 |
tile = _TILE_SIZE
|
| 108 |
+
overlap = tile // 4
|
| 109 |
+
stride = tile - overlap
|
| 110 |
|
| 111 |
pad_h = (tile - h % tile) % tile
|
| 112 |
pad_w = (tile - w % tile) % tile
|
|
|
|
| 118 |
score_sum = np.zeros((ph, pw), dtype=np.float32)
|
| 119 |
count = np.zeros((ph, pw), dtype=np.float32)
|
| 120 |
|
|
|
|
| 121 |
ramp = np.linspace(0, 1, overlap)
|
| 122 |
flat = np.ones(tile - 2 * overlap)
|
| 123 |
profile = np.concatenate([ramp, flat, ramp[::-1]])
|
scripts/validate_detection.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Lightweight validation for the change detection pipeline.
|
| 3 |
+
Run from change_detection_webapp: python scripts/validate_detection.py
|
| 4 |
+
"""
|
| 5 |
+
import sys
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
from PIL import Image
|
| 10 |
+
|
| 11 |
+
ROOT = Path(__file__).resolve().parent.parent
|
| 12 |
+
sys.path.insert(0, str(ROOT))
|
| 13 |
+
|
| 14 |
+
from app.detection_engine import ( # noqa: E402
|
| 15 |
+
register_images,
|
| 16 |
+
run_detection,
|
| 17 |
+
fuse_dl_and_classical,
|
| 18 |
+
)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def test_registration_identical_pair():
|
| 22 |
+
rng = np.random.default_rng(42)
|
| 23 |
+
img = rng.integers(0, 255, (320, 320, 3), dtype=np.uint8)
|
| 24 |
+
b, a, ok, meta = register_images(img, img.copy())
|
| 25 |
+
assert meta.get("ncc", 0) >= 0.5 or ok, f"weak NCC on identical pair: {meta}"
|
| 26 |
+
print(" registration identical pair:", ok, meta)
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def test_registration_with_shift():
|
| 30 |
+
img = np.zeros((400, 400, 3), dtype=np.uint8)
|
| 31 |
+
img[80:200, 80:200] = [180, 90, 60]
|
| 32 |
+
shifted = np.roll(np.roll(img, 8, axis=0), 5, axis=1)
|
| 33 |
+
b, a, ok, meta = register_images(img, shifted)
|
| 34 |
+
print(" registration shifted pair:", ok, "ncc=", meta.get("ncc"))
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def test_fusion_shapes():
|
| 38 |
+
h, w = 128, 128
|
| 39 |
+
dl = np.zeros((h, w), dtype=np.float32)
|
| 40 |
+
dl[40:80, 40:80] = 0.8
|
| 41 |
+
cl = np.zeros((h, w), dtype=np.float32)
|
| 42 |
+
cl[50:90, 50:90] = 0.7
|
| 43 |
+
img = np.full((h, w, 3), 128, dtype=np.uint8)
|
| 44 |
+
mask, score, dbg = fuse_dl_and_classical(dl, cl, img, img, sensitivity=0.5)
|
| 45 |
+
assert mask.shape == (h, w)
|
| 46 |
+
assert score.shape == (h, w)
|
| 47 |
+
assert dbg.get("fused_changed_px", 0) >= 0
|
| 48 |
+
print(" fusion:", dbg.get("fused_changed_px"), "px")
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def test_run_detection_synthetic():
|
| 52 |
+
rng = np.random.default_rng(0)
|
| 53 |
+
before = rng.integers(0, 255, (256, 256, 3), dtype=np.uint8)
|
| 54 |
+
after = before.copy()
|
| 55 |
+
after[100:180, 100:180] = [40, 180, 40]
|
| 56 |
+
mask, _, stats, regions = run_detection(
|
| 57 |
+
Image.fromarray(before),
|
| 58 |
+
Image.fromarray(after),
|
| 59 |
+
method="AI-Based Deep Learning",
|
| 60 |
+
enable_registration=True,
|
| 61 |
+
enable_normalization=True,
|
| 62 |
+
detection_sensitivity=0.5,
|
| 63 |
+
)
|
| 64 |
+
assert mask.shape[:2] == (256, 256)
|
| 65 |
+
ratio = stats["change_percentage"]
|
| 66 |
+
assert 0 <= ratio <= 100
|
| 67 |
+
assert "params" in stats
|
| 68 |
+
assert not stats.get("threshold_debug", {}).get("fallback_used", False)
|
| 69 |
+
print(" run_detection: change%=", f"{ratio:.2f}", "regions=", len(regions))
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def main():
|
| 73 |
+
print("validate_detection.py")
|
| 74 |
+
test_registration_identical_pair()
|
| 75 |
+
test_registration_with_shift()
|
| 76 |
+
test_fusion_shapes()
|
| 77 |
+
test_run_detection_synthetic()
|
| 78 |
+
print("All checks passed.")
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
if __name__ == "__main__":
|
| 82 |
+
main()
|
static/css/style.css
CHANGED
|
@@ -604,6 +604,16 @@ input:focus, select:focus, textarea:focus {
|
|
| 604 |
.stat-box .value-sm {
|
| 605 |
font-size: clamp(0.75rem, 3vw, 1.05rem);
|
| 606 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 607 |
@media (max-width: 640px) {
|
| 608 |
.stat-box-wide { grid-column: span 1; }
|
| 609 |
}
|
|
|
|
| 604 |
.stat-box .value-sm {
|
| 605 |
font-size: clamp(0.75rem, 3vw, 1.05rem);
|
| 606 |
}
|
| 607 |
+
.result-warning {
|
| 608 |
+
grid-column: 1 / -1;
|
| 609 |
+
padding: 0.65rem 0.85rem;
|
| 610 |
+
margin-bottom: 0.5rem;
|
| 611 |
+
border-radius: 6px;
|
| 612 |
+
background: #fef3c7;
|
| 613 |
+
color: #92400e;
|
| 614 |
+
font-size: 0.85rem;
|
| 615 |
+
line-height: 1.4;
|
| 616 |
+
}
|
| 617 |
@media (max-width: 640px) {
|
| 618 |
.stat-box-wide { grid-column: span 1; }
|
| 619 |
}
|
static/js/app.js
CHANGED
|
@@ -530,12 +530,30 @@ function showResult(data) {
|
|
| 530 |
const chPx = stats.changedPixels ?? 0;
|
| 531 |
const totPx = stats.totalPixels ?? 0;
|
| 532 |
|
| 533 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 534 |
<div class="stat-box"><div class="value">${pct}%</div><div class="label">Changed</div></div>
|
| 535 |
<div class="stat-box"><div class="value" title="${chPx.toLocaleString()}">${formatCompact(chPx)}</div><div class="label">Changed px</div></div>
|
| 536 |
<div class="stat-box"><div class="value" title="${totPx.toLocaleString()}">${formatCompact(totPx)}</div><div class="label">Total px</div></div>
|
| 537 |
<div class="stat-box"><div class="value">${(data.regions || []).length}</div><div class="label">Regions</div></div>
|
| 538 |
<div class="stat-box stat-box-wide"><div class="value value-sm" title="${locLabel}">${locLabel}</div><div class="label">Location</div></div>
|
|
|
|
|
|
|
| 539 |
`;
|
| 540 |
|
| 541 |
const beforeImg = document.getElementById('compare-before-img');
|
|
|
|
| 530 |
const chPx = stats.changedPixels ?? 0;
|
| 531 |
const totPx = stats.totalPixels ?? 0;
|
| 532 |
|
| 533 |
+
const regOk = stats.registrationOk;
|
| 534 |
+
const alignWarn = stats.alignmentWarning;
|
| 535 |
+
const thrDbg = stats.thresholdDebug || {};
|
| 536 |
+
const fusionPx = thrDbg.fused_changed_px != null
|
| 537 |
+
? `DL ${thrDbg.dl_changed_px ?? '—'} / fused ${thrDbg.fused_changed_px}`
|
| 538 |
+
: (thrDbg.model_changed_px != null
|
| 539 |
+
? `Model ${thrDbg.model_changed_px} / rule ${thrDbg.rule_changed_px ?? '—'}`
|
| 540 |
+
: '');
|
| 541 |
+
|
| 542 |
+
let warnHtml = '';
|
| 543 |
+
if (alignWarn) {
|
| 544 |
+
warnHtml = `<div class="result-warning" role="alert">${alignWarn}</div>`;
|
| 545 |
+
} else if (regOk === false) {
|
| 546 |
+
warnHtml = '<div class="result-warning" role="alert">Image alignment was weak — results may include false detections.</div>';
|
| 547 |
+
}
|
| 548 |
+
|
| 549 |
+
statsEl.innerHTML = warnHtml + `
|
| 550 |
<div class="stat-box"><div class="value">${pct}%</div><div class="label">Changed</div></div>
|
| 551 |
<div class="stat-box"><div class="value" title="${chPx.toLocaleString()}">${formatCompact(chPx)}</div><div class="label">Changed px</div></div>
|
| 552 |
<div class="stat-box"><div class="value" title="${totPx.toLocaleString()}">${formatCompact(totPx)}</div><div class="label">Total px</div></div>
|
| 553 |
<div class="stat-box"><div class="value">${(data.regions || []).length}</div><div class="label">Regions</div></div>
|
| 554 |
<div class="stat-box stat-box-wide"><div class="value value-sm" title="${locLabel}">${locLabel}</div><div class="label">Location</div></div>
|
| 555 |
+
${fusionPx ? `<div class="stat-box stat-box-wide"><div class="value value-sm">${fusionPx}</div><div class="label">Fusion px</div></div>` : ''}
|
| 556 |
+
<div class="stat-box"><div class="value value-sm">${regOk === true ? 'OK' : regOk === false ? 'Weak' : '—'}</div><div class="label">Alignment</div></div>
|
| 557 |
`;
|
| 558 |
|
| 559 |
const beforeImg = document.getElementById('compare-before-img');
|
templates/index.html
CHANGED
|
@@ -4,7 +4,7 @@
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>AI Change Detection</title>
|
| 7 |
-
<link rel="stylesheet" href="/static/css/style.css?v=
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div class="app">
|
|
@@ -203,6 +203,9 @@
|
|
| 203 |
<label for="detect-title">Title</label>
|
| 204 |
<input type="text" id="detect-title" value="Untitled run" placeholder="Run title" />
|
| 205 |
</div>
|
|
|
|
|
|
|
|
|
|
| 206 |
<div class="form-group">
|
| 207 |
<label for="detect-method">Method</label>
|
| 208 |
<select id="detect-method">
|
|
@@ -361,6 +364,6 @@
|
|
| 361 |
</div>
|
| 362 |
</div>
|
| 363 |
|
| 364 |
-
<script src="/static/js/app.js?v=
|
| 365 |
</body>
|
| 366 |
</html>
|
|
|
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
<title>AI Change Detection</title>
|
| 7 |
+
<link rel="stylesheet" href="/static/css/style.css?v=26" />
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div class="app">
|
|
|
|
| 203 |
<label for="detect-title">Title</label>
|
| 204 |
<input type="text" id="detect-title" value="Untitled run" placeholder="Run title" />
|
| 205 |
</div>
|
| 206 |
+
<p class="form-hint" style="margin:0 0 0.75rem;font-size:0.85rem;color:#6b7280;">
|
| 207 |
+
For Google Earth screenshots: use the same map location, zoom level, and crop for before and after images.
|
| 208 |
+
</p>
|
| 209 |
<div class="form-group">
|
| 210 |
<label for="detect-method">Method</label>
|
| 211 |
<select id="detect-method">
|
|
|
|
| 364 |
</div>
|
| 365 |
</div>
|
| 366 |
|
| 367 |
+
<script src="/static/js/app.js?v=41"></script>
|
| 368 |
</body>
|
| 369 |
</html>
|