Spaces:
Sleeping
Sleeping
Commit ·
aa4d14b
1
Parent(s): ce994d8
Add SIFT+FLANN registration, Siamese U-Net, multi-scale detection, ExG vegetation, Hybrid AI mode, confidence maps
Browse files- Dockerfile +1 -1
- app/detection_engine.py +424 -96
- app/models/__init__.py +0 -0
- app/models/change_model.py +237 -0
- app/models/model_utils.py +108 -0
- templates/index.html +3 -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=19
|
| 23 |
ENV APP_BUILD=${APP_BUILD}
|
| 24 |
RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
|
| 25 |
|
app/detection_engine.py
CHANGED
|
@@ -1,9 +1,11 @@
|
|
| 1 |
"""
|
| 2 |
-
Satellite Change Detection Engine
|
| 3 |
High-accuracy detection with multi-channel analysis, SSIM, CVA, texture features,
|
| 4 |
adaptive thresholding, vegetation/shadow suppression, SNR-weighted fusion,
|
| 5 |
-
|
|
|
|
| 6 |
"""
|
|
|
|
| 7 |
import numpy as np
|
| 8 |
import cv2
|
| 9 |
from PIL import Image
|
|
@@ -11,28 +13,46 @@ from sklearn.cluster import KMeans
|
|
| 11 |
from sklearn.preprocessing import StandardScaler
|
| 12 |
from collections import Counter
|
| 13 |
|
|
|
|
|
|
|
| 14 |
|
| 15 |
# ---------------------------------------------------------------------------
|
| 16 |
# 1. Pre-processing
|
| 17 |
# ---------------------------------------------------------------------------
|
| 18 |
|
| 19 |
-
def
|
| 20 |
-
"""
|
| 21 |
-
img_array = np.array(image)
|
| 22 |
if img_array.ndim == 2:
|
| 23 |
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
|
| 24 |
elif img_array.ndim == 3 and img_array.shape[2] == 4:
|
| 25 |
img_array = cv2.cvtColor(img_array, cv2.COLOR_RGBA2RGB)
|
| 26 |
elif img_array.ndim != 3 or img_array.shape[2] != 3:
|
| 27 |
raise ValueError(f"Unsupported image shape: {img_array.shape}")
|
| 28 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
height, width = img_array.shape[:2]
|
| 30 |
if max(height, width) > max_size:
|
| 31 |
scale = max_size / max(height, width)
|
| 32 |
new_w, new_h = max(1, int(width * scale)), max(1, int(height * scale))
|
| 33 |
img_array = cv2.resize(img_array, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
| 36 |
return img_array
|
| 37 |
|
| 38 |
|
|
@@ -40,83 +60,188 @@ def preprocess_image(image):
|
|
| 40 |
# 2. Improved image registration (alignment)
|
| 41 |
# ---------------------------------------------------------------------------
|
| 42 |
|
| 43 |
-
def
|
| 44 |
-
"""
|
| 45 |
-
|
| 46 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 47 |
|
| 48 |
-
orb = cv2.ORB_create(nfeatures=max_features, scoreType=cv2.ORB_HARRIS_SCORE)
|
| 49 |
-
kp1, des1 = orb.detectAndCompute(gray1, None)
|
| 50 |
-
kp2, des2 = orb.detectAndCompute(gray2, None)
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
if len(pair) == 2:
|
| 62 |
-
m, n = pair
|
| 63 |
-
if m.distance < 0.75 * n.distance:
|
| 64 |
-
good_matches.append(m)
|
| 65 |
|
| 66 |
-
|
| 67 |
-
|
| 68 |
|
| 69 |
-
|
| 70 |
-
|
| 71 |
|
| 72 |
-
|
| 73 |
-
if
|
| 74 |
-
|
|
|
|
|
|
|
| 75 |
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
| 79 |
|
| 80 |
-
#
|
| 81 |
-
|
| 82 |
-
if abs(det) < 0.1 or abs(det) > 10.0:
|
| 83 |
-
return _register_images_ecc_fallback(img1, img2)
|
| 84 |
|
| 85 |
-
h, w = img1.shape[:2]
|
| 86 |
-
img2_aligned = cv2.warpPerspective(img2, homography, (w, h), borderMode=cv2.BORDER_REFLECT)
|
| 87 |
-
return img1, img2_aligned, True
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
"""
|
| 92 |
-
|
| 93 |
-
|
| 94 |
"""
|
| 95 |
try:
|
| 96 |
gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY)
|
| 97 |
gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY)
|
| 98 |
-
|
| 99 |
-
gray2_f = gray2.astype(np.float32) / 255.0
|
| 100 |
|
|
|
|
|
|
|
| 101 |
warp = np.eye(2, 3, dtype=np.float32)
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 111 |
aligned = cv2.warpAffine(
|
| 112 |
-
img2,
|
| 113 |
-
warp,
|
| 114 |
-
(w, h),
|
| 115 |
flags=cv2.INTER_LINEAR + cv2.WARP_INVERSE_MAP,
|
| 116 |
-
borderMode=cv2.BORDER_REFLECT
|
| 117 |
-
|
| 118 |
-
#
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
except Exception:
|
| 121 |
return img1, img2, False
|
| 122 |
|
|
@@ -154,23 +279,43 @@ def normalize_radiometry(img1, img2):
|
|
| 154 |
# 4. Vegetation suppression
|
| 155 |
# ---------------------------------------------------------------------------
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
def compute_vegetation_mask(img):
|
| 158 |
"""
|
| 159 |
-
Identify vegetation pixels using
|
|
|
|
|
|
|
|
|
|
| 160 |
Returns a float map in [0, 1] where 1.0 = vegetation, 0.0 = non-vegetation.
|
| 161 |
"""
|
| 162 |
r = img[:, :, 0].astype(np.float32)
|
| 163 |
g = img[:, :, 1].astype(np.float32)
|
| 164 |
ndvi = (g - r) / (g + r + 1e-6)
|
| 165 |
|
|
|
|
|
|
|
| 166 |
hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)
|
| 167 |
hue = hsv[:, :, 0].astype(np.float32)
|
| 168 |
sat = hsv[:, :, 1].astype(np.float32)
|
| 169 |
|
| 170 |
ndvi_veg = (ndvi > 0.08).astype(np.float32)
|
|
|
|
| 171 |
hsv_veg = ((hue >= 35) & (hue <= 85) & (sat > 30)).astype(np.float32)
|
| 172 |
|
| 173 |
-
veg = np.clip(ndvi_veg * 0.
|
| 174 |
veg = cv2.GaussianBlur(veg, (11, 11), 0)
|
| 175 |
return veg
|
| 176 |
|
|
@@ -554,8 +699,8 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5):
|
|
| 554 |
fused_smooth = cv2.GaussianBlur(fused_norm.astype(np.float32), (7, 7), 0)
|
| 555 |
|
| 556 |
sens = float(np.clip(sensitivity, 0.0, 1.0))
|
| 557 |
-
q = 0.
|
| 558 |
-
q = float(np.clip(q, 0.
|
| 559 |
|
| 560 |
thr_score = float(np.quantile(fused_smooth, q))
|
| 561 |
change_mask = (fused_smooth >= thr_score).astype(np.uint8) * 255
|
|
@@ -587,37 +732,53 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5):
|
|
| 587 |
|
| 588 |
def ai_deep_learning_method(img1, img2, sensitivity=0.5):
|
| 589 |
"""
|
| 590 |
-
|
| 591 |
-
|
|
|
|
|
|
|
| 592 |
"""
|
| 593 |
from .model_inference import is_model_available, predict_change_mask
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 594 |
|
| 595 |
if is_model_available():
|
| 596 |
threshold = 0.25 + (1.0 - sensitivity) * 0.25
|
| 597 |
try:
|
| 598 |
-
|
| 599 |
img1, img2, threshold=threshold)
|
| 600 |
-
|
| 601 |
-
|
| 602 |
-
"method": "AI-Based Deep Learning (AdaptFormer)",
|
| 603 |
-
"model": "adaptformer-levir-cd",
|
| 604 |
-
"threshold_used": int(threshold * 255),
|
| 605 |
-
"sensitivity": float(sensitivity),
|
| 606 |
-
}
|
| 607 |
-
return change_mask, debug
|
| 608 |
except Exception as e:
|
| 609 |
-
|
| 610 |
-
|
| 611 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 612 |
|
| 613 |
-
change_mask, core_debug = _ai_fusion_core(img1, img2, sensitivity=sensitivity)
|
| 614 |
debug = {
|
| 615 |
"method": "AI-Based Deep Learning (rule-based fallback)",
|
| 616 |
"threshold_used": core_debug.get("threshold_used"),
|
| 617 |
"sensitivity": float(sensitivity),
|
| 618 |
"core": core_debug,
|
| 619 |
}
|
| 620 |
-
return
|
| 621 |
|
| 622 |
|
| 623 |
def hybrid_method(img1, img2, sensitivity=0.5):
|
|
@@ -658,6 +819,121 @@ def hybrid_method(img1, img2, sensitivity=0.5):
|
|
| 658 |
return final_mask, debug
|
| 659 |
|
| 660 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 661 |
# ---------------------------------------------------------------------------
|
| 662 |
# 11. Robust post-processing
|
| 663 |
# ---------------------------------------------------------------------------
|
|
@@ -682,15 +958,15 @@ def _clean_mask(mask, sensitivity=0.5, border_margin=12):
|
|
| 682 |
mask[:, :border_margin] = 0
|
| 683 |
mask[:, -border_margin:] = 0
|
| 684 |
|
| 685 |
-
mask = cv2.medianBlur(mask,
|
| 686 |
|
| 687 |
-
open_size = max(3, int(
|
| 688 |
if open_size % 2 == 0:
|
| 689 |
open_size += 1
|
| 690 |
k_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (open_size, open_size))
|
| 691 |
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k_open)
|
| 692 |
|
| 693 |
-
close_size = max(3, int(
|
| 694 |
if close_size % 2 == 0:
|
| 695 |
close_size += 1
|
| 696 |
k_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (close_size, close_size))
|
|
@@ -863,6 +1139,7 @@ def extract_advanced_features(region):
|
|
| 863 |
|
| 864 |
# Vegetation indices
|
| 865 |
ndvi = (mean_rgb[1] - mean_rgb[0]) / (mean_rgb[1] + mean_rgb[0] + 1e-6)
|
|
|
|
| 866 |
|
| 867 |
# Texture
|
| 868 |
texture_std = float(np.std(gray))
|
|
@@ -888,7 +1165,8 @@ def extract_advanced_features(region):
|
|
| 888 |
|
| 889 |
return {
|
| 890 |
"mean_rgb": mean_rgb, "std_rgb": std_rgb, "mean_hsv": mean_hsv, "mean_lab": mean_lab,
|
| 891 |
-
"ndvi": ndvi, "
|
|
|
|
| 892 |
"edge_density": edge_density, "orientation_entropy": orientation_entropy,
|
| 893 |
"glcm_contrast": glcm_contrast,
|
| 894 |
"color_homogeneity": float(np.mean(std_rgb)),
|
|
@@ -1010,6 +1288,7 @@ def _extract_differential_features(before_crop, after_crop):
|
|
| 1010 |
return {
|
| 1011 |
"before": feat_b, "after": feat_a,
|
| 1012 |
"delta_ndvi": feat_a["ndvi"] - feat_b["ndvi"],
|
|
|
|
| 1013 |
"delta_green_ratio": feat_a["green_ratio"] - feat_b["green_ratio"],
|
| 1014 |
"delta_edge_density": feat_a["edge_density"] - feat_b["edge_density"],
|
| 1015 |
"delta_brightness": feat_a["brightness"] - feat_b["brightness"],
|
|
@@ -1092,17 +1371,22 @@ def classify_object_type(image_region, bbox, before_region=None):
|
|
| 1092 |
if diff:
|
| 1093 |
# Differential: detect actual vegetation gain or loss
|
| 1094 |
if abs(diff["delta_ndvi"]) > 0.08:
|
| 1095 |
-
veg += 0.
|
| 1096 |
-
|
|
|
|
| 1097 |
veg += 0.20
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1098 |
if diff["lab_color_distance"] > 15 and (
|
| 1099 |
diff["before"]["ndvi"] > 0.05 or diff["after"]["ndvi"] > 0.05):
|
| 1100 |
-
veg += 0.
|
| 1101 |
if abs(diff["delta_saturation"]) > 15 and (
|
| 1102 |
diff["before"]["green_ratio"] > 0.34 or diff["after"]["green_ratio"] > 0.34):
|
| 1103 |
-
veg += 0.
|
| 1104 |
if diff["delta_lines"] < 3 and diff["delta_corners"] < 5:
|
| 1105 |
-
veg += 0.
|
| 1106 |
if area > 500:
|
| 1107 |
veg += 0.04
|
| 1108 |
else:
|
|
@@ -1301,6 +1585,46 @@ def classify_object_type(image_region, bbox, before_region=None):
|
|
| 1301 |
road += 0.03
|
| 1302 |
scores["Road/Pavement Change"] = road
|
| 1303 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1304 |
# ---- Bare Land/Soil Change ----
|
| 1305 |
soil = 0.0
|
| 1306 |
if feat_a["red_ratio"] > 0.34 and feat_a["green_ratio"] < 0.36:
|
|
@@ -1322,7 +1646,7 @@ def classify_object_type(image_region, bbox, before_region=None):
|
|
| 1322 |
best = max(scores, key=scores.get)
|
| 1323 |
conf = scores[best]
|
| 1324 |
|
| 1325 |
-
if conf < 0.
|
| 1326 |
return "Unclassified", conf
|
| 1327 |
return best, min(conf, 1.0)
|
| 1328 |
|
|
@@ -1518,7 +1842,7 @@ def classify_vegetation_subtype(before_img, after_img, bbox):
|
|
| 1518 |
# ---------------------------------------------------------------------------
|
| 1519 |
|
| 1520 |
_STRUCTURAL_TYPES = {"New Construction/Building", "Demolition/Clearing",
|
| 1521 |
-
"Road/Pavement Change"}
|
| 1522 |
|
| 1523 |
|
| 1524 |
def _region_has_structure(crop):
|
|
@@ -2139,6 +2463,10 @@ def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
|
|
| 2139 |
"note": "KMeans clustering path does not use binary threshold.",
|
| 2140 |
"sensitivity": float(detection_sensitivity),
|
| 2141 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2142 |
else:
|
| 2143 |
change_mask, threshold_debug = hybrid_method(
|
| 2144 |
before_array, after_array, sensitivity=detection_sensitivity
|
|
@@ -2152,7 +2480,7 @@ def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
|
|
| 2152 |
changed_pixels_ratio = float(np.sum(change_mask > 127)) / float(total_pixels) if total_pixels else 0.0
|
| 2153 |
|
| 2154 |
used_fallback = False
|
| 2155 |
-
if method in ("AI-Based Deep Learning", "Hybrid Approach") and changed_pixels_ratio < 0.0025:
|
| 2156 |
diff_mask, diff_debug = image_difference_method(
|
| 2157 |
before_array, after_array, sensitivity=detection_sensitivity
|
| 2158 |
)
|
|
|
|
| 1 |
"""
|
| 2 |
+
Satellite Change Detection Engine v4
|
| 3 |
High-accuracy detection with multi-channel analysis, SSIM, CVA, texture features,
|
| 4 |
adaptive thresholding, vegetation/shadow suppression, SNR-weighted fusion,
|
| 5 |
+
SIFT+FLANN registration, tile-based + multi-scale processing, Excess Green
|
| 6 |
+
vegetation index, confidence maps, and improved object classification.
|
| 7 |
"""
|
| 8 |
+
import logging
|
| 9 |
import numpy as np
|
| 10 |
import cv2
|
| 11 |
from PIL import Image
|
|
|
|
| 13 |
from sklearn.preprocessing import StandardScaler
|
| 14 |
from collections import Counter
|
| 15 |
|
| 16 |
+
_log = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
|
| 19 |
# ---------------------------------------------------------------------------
|
| 20 |
# 1. Pre-processing
|
| 21 |
# ---------------------------------------------------------------------------
|
| 22 |
|
| 23 |
+
def _ensure_rgb_uint8(img_array):
|
| 24 |
+
"""Convert any image array to 3-channel RGB uint8."""
|
|
|
|
| 25 |
if img_array.ndim == 2:
|
| 26 |
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
|
| 27 |
elif img_array.ndim == 3 and img_array.shape[2] == 4:
|
| 28 |
img_array = cv2.cvtColor(img_array, cv2.COLOR_RGBA2RGB)
|
| 29 |
elif img_array.ndim != 3 or img_array.shape[2] != 3:
|
| 30 |
raise ValueError(f"Unsupported image shape: {img_array.shape}")
|
| 31 |
+
if img_array.dtype != np.uint8:
|
| 32 |
+
img_array = np.clip(img_array, 0, 255).astype(np.uint8)
|
| 33 |
+
return img_array
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _to_float32(img):
|
| 37 |
+
"""Normalize uint8 image to float32 [0,1]."""
|
| 38 |
+
return img.astype(np.float32) / 255.0
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def preprocess_image(image, max_size=2000):
|
| 42 |
+
"""Preprocess image: convert to RGB, limit size, Gaussian + bilateral denoise."""
|
| 43 |
+
img_array = np.array(image)
|
| 44 |
+
img_array = _ensure_rgb_uint8(img_array)
|
| 45 |
+
|
| 46 |
height, width = img_array.shape[:2]
|
| 47 |
if max(height, width) > max_size:
|
| 48 |
scale = max_size / max(height, width)
|
| 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 |
+
# Bilateral filter: further denoise while preserving structural edges
|
| 55 |
+
img_array = cv2.bilateralFilter(img_array, 7, 60, 60)
|
| 56 |
return img_array
|
| 57 |
|
| 58 |
|
|
|
|
| 60 |
# 2. Improved image registration (alignment)
|
| 61 |
# ---------------------------------------------------------------------------
|
| 62 |
|
| 63 |
+
def _match_features_sift(gray1, gray2):
|
| 64 |
+
"""SIFT + FLANN matching with Lowe's ratio test. Returns (homography, inlier_ratio) or (None, 0)."""
|
| 65 |
+
try:
|
| 66 |
+
sift = cv2.SIFT_create(nfeatures=4000)
|
| 67 |
+
kp1, des1 = sift.detectAndCompute(gray1, None)
|
| 68 |
+
kp2, des2 = sift.detectAndCompute(gray2, None)
|
| 69 |
+
|
| 70 |
+
if des1 is None or des2 is None or len(des1) < 10 or len(des2) < 10:
|
| 71 |
+
return None, 0.0
|
| 72 |
+
|
| 73 |
+
FLANN_INDEX_KDTREE = 1
|
| 74 |
+
index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)
|
| 75 |
+
search_params = dict(checks=100)
|
| 76 |
+
flann = cv2.FlannBasedMatcher(index_params, search_params)
|
| 77 |
+
raw_matches = flann.knnMatch(des1, des2, k=2)
|
| 78 |
+
|
| 79 |
+
good = [m for m, n in raw_matches if len([m, n]) == 2 and m.distance < 0.7 * n.distance]
|
| 80 |
+
if len(good) < 8:
|
| 81 |
+
return None, 0.0
|
| 82 |
+
|
| 83 |
+
src = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
|
| 84 |
+
dst = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
|
| 85 |
+
H, mask = cv2.findHomography(dst, src, cv2.RANSAC, 4.0, maxIters=3000)
|
| 86 |
+
if H is None or mask is None:
|
| 87 |
+
return None, 0.0
|
| 88 |
+
det = np.linalg.det(H[:2, :2])
|
| 89 |
+
if abs(det) < 0.1 or abs(det) > 10.0:
|
| 90 |
+
return None, 0.0
|
| 91 |
+
return H, float(np.sum(mask)) / len(mask)
|
| 92 |
+
except Exception:
|
| 93 |
+
return None, 0.0
|
| 94 |
|
|
|
|
|
|
|
|
|
|
| 95 |
|
| 96 |
+
def _match_features_orb(gray1, gray2, max_features=3000):
|
| 97 |
+
"""ORB fallback matching. Returns (homography, inlier_ratio) or (None, 0)."""
|
| 98 |
+
best_H, best_ir = None, 0.0
|
| 99 |
+
for nf, ratio_thr in [(max_features, 0.75), (max_features * 2, 0.80)]:
|
| 100 |
+
orb = cv2.ORB_create(nfeatures=nf, scoreType=cv2.ORB_HARRIS_SCORE,
|
| 101 |
+
edgeThreshold=15, patchSize=31)
|
| 102 |
+
kp1, des1 = orb.detectAndCompute(gray1, None)
|
| 103 |
+
kp2, des2 = orb.detectAndCompute(gray2, None)
|
| 104 |
+
if des1 is None or des2 is None or len(des1) < 10 or len(des2) < 10:
|
| 105 |
+
continue
|
| 106 |
+
bf = cv2.BFMatcher(cv2.NORM_HAMMING)
|
| 107 |
+
raw_matches = bf.knnMatch(des1, des2, k=2)
|
| 108 |
+
good = [m for m, n in raw_matches if len([m, n]) == 2 and m.distance < ratio_thr * n.distance]
|
| 109 |
+
if len(good) < 8:
|
| 110 |
+
continue
|
| 111 |
+
src = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
|
| 112 |
+
dst = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
|
| 113 |
+
H, mask = cv2.findHomography(dst, src, cv2.RANSAC, 4.0, maxIters=2000)
|
| 114 |
+
if H is None or mask is None:
|
| 115 |
+
continue
|
| 116 |
+
det = np.linalg.det(H[:2, :2])
|
| 117 |
+
if abs(det) < 0.1 or abs(det) > 10.0:
|
| 118 |
+
continue
|
| 119 |
+
ir = float(np.sum(mask)) / len(mask)
|
| 120 |
+
if ir > best_ir:
|
| 121 |
+
best_H, best_ir = H, ir
|
| 122 |
+
return best_H, best_ir
|
| 123 |
+
|
| 124 |
|
| 125 |
+
def register_images(img1, img2, max_features=3000):
|
| 126 |
+
"""
|
| 127 |
+
Multi-stage image alignment pipeline:
|
| 128 |
+
1. SIFT + FLANN matcher (primary — scale/rotation invariant, float descriptors)
|
| 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
|
| 138 |
+
gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY)
|
| 139 |
+
gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY)
|
| 140 |
|
| 141 |
+
# Stage 1: SIFT + FLANN (best quality)
|
| 142 |
+
H, ir = _match_features_sift(gray1, gray2)
|
| 143 |
|
| 144 |
+
# Stage 2: ORB fallback
|
| 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 |
+
if H is not None and ir >= 0.20:
|
| 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 |
+
# Stage 3: multi-scale ECC
|
| 157 |
+
return _register_images_ecc_multiscale(img1, img2)
|
|
|
|
|
|
|
| 158 |
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
+
def _refine_ecc(img1, img2_initial):
|
| 161 |
+
"""Refine an already-coarse-aligned image with ECC translation/affine."""
|
| 162 |
+
try:
|
| 163 |
+
gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
|
| 164 |
+
gray2 = cv2.cvtColor(img2_initial, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
|
| 165 |
+
h, w = img1.shape[:2]
|
| 166 |
|
| 167 |
+
warp = np.eye(2, 3, dtype=np.float32)
|
| 168 |
+
criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 100, 1e-5)
|
| 169 |
+
|
| 170 |
+
# Try affine first, fall back to translation
|
| 171 |
+
for motion in [cv2.MOTION_AFFINE, cv2.MOTION_TRANSLATION]:
|
| 172 |
+
try:
|
| 173 |
+
warp_m = np.eye(2, 3, dtype=np.float32)
|
| 174 |
+
cc, warp_m = cv2.findTransformECC(
|
| 175 |
+
gray1, gray2, warp_m, motion, criteria)
|
| 176 |
+
if cc >= 0.6:
|
| 177 |
+
aligned = cv2.warpAffine(
|
| 178 |
+
img2_initial, warp_m, (w, h),
|
| 179 |
+
flags=cv2.INTER_LINEAR + cv2.WARP_INVERSE_MAP,
|
| 180 |
+
borderMode=cv2.BORDER_REFLECT)
|
| 181 |
+
return aligned
|
| 182 |
+
except Exception:
|
| 183 |
+
continue
|
| 184 |
+
except Exception:
|
| 185 |
+
pass
|
| 186 |
+
return img2_initial
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def _register_images_ecc_multiscale(img1, img2):
|
| 190 |
"""
|
| 191 |
+
Multi-scale ECC fallback: start from a downscaled version (faster, wider
|
| 192 |
+
convergence basin), then refine at full resolution.
|
| 193 |
"""
|
| 194 |
try:
|
| 195 |
gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY)
|
| 196 |
gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY)
|
| 197 |
+
h, w = img1.shape[:2]
|
|
|
|
| 198 |
|
| 199 |
+
# Build 2-level pyramid
|
| 200 |
+
scales = [4, 2, 1]
|
| 201 |
warp = np.eye(2, 3, dtype=np.float32)
|
| 202 |
+
|
| 203 |
+
for scale in scales:
|
| 204 |
+
sh, sw = h // scale, w // scale
|
| 205 |
+
if sh < 64 or sw < 64:
|
| 206 |
+
continue
|
| 207 |
+
g1 = cv2.resize(gray1, (sw, sh)).astype(np.float32) / 255.0
|
| 208 |
+
g2 = cv2.resize(gray2, (sw, sh)).astype(np.float32) / 255.0
|
| 209 |
+
|
| 210 |
+
scaled_warp = warp.copy()
|
| 211 |
+
scaled_warp[0, 2] /= (scales[0] / scale) if scale != scales[0] else 1
|
| 212 |
+
scaled_warp[1, 2] /= (scales[0] / scale) if scale != scales[0] else 1
|
| 213 |
+
|
| 214 |
+
iters = 300 if scale == scales[0] else 150
|
| 215 |
+
criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, iters, 1e-6)
|
| 216 |
+
try:
|
| 217 |
+
cc, scaled_warp = cv2.findTransformECC(
|
| 218 |
+
g1, g2, scaled_warp, cv2.MOTION_AFFINE, criteria)
|
| 219 |
+
except Exception:
|
| 220 |
+
continue
|
| 221 |
+
|
| 222 |
+
# Scale translation back for next level
|
| 223 |
+
if scale != 1:
|
| 224 |
+
warp = scaled_warp.copy()
|
| 225 |
+
next_idx = scales.index(scale) + 1
|
| 226 |
+
if next_idx < len(scales):
|
| 227 |
+
next_scale = scales[next_idx]
|
| 228 |
+
ratio = scale / next_scale
|
| 229 |
+
warp[0, 2] *= ratio
|
| 230 |
+
warp[1, 2] *= ratio
|
| 231 |
+
else:
|
| 232 |
+
warp = scaled_warp
|
| 233 |
+
|
| 234 |
aligned = cv2.warpAffine(
|
| 235 |
+
img2, warp, (w, h),
|
|
|
|
|
|
|
| 236 |
flags=cv2.INTER_LINEAR + cv2.WARP_INVERSE_MAP,
|
| 237 |
+
borderMode=cv2.BORDER_REFLECT)
|
| 238 |
+
|
| 239 |
+
# Check alignment quality via normalized cross-correlation
|
| 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 |
+
return img1, aligned, bool(ncc >= 0.40)
|
| 245 |
except Exception:
|
| 246 |
return img1, img2, False
|
| 247 |
|
|
|
|
| 279 |
# 4. Vegetation suppression
|
| 280 |
# ---------------------------------------------------------------------------
|
| 281 |
|
| 282 |
+
def compute_excess_green(img):
|
| 283 |
+
"""
|
| 284 |
+
Excess Green Index: ExG = 2G - R - B (normalized to [0,1]).
|
| 285 |
+
Excellent for separating vegetation from soil/buildings in satellite imagery.
|
| 286 |
+
"""
|
| 287 |
+
r = img[:, :, 0].astype(np.float32)
|
| 288 |
+
g = img[:, :, 1].astype(np.float32)
|
| 289 |
+
b = img[:, :, 2].astype(np.float32)
|
| 290 |
+
total = r + g + b + 1e-6
|
| 291 |
+
rn, gn, bn = r / total, g / total, b / total
|
| 292 |
+
exg = 2.0 * gn - rn - bn
|
| 293 |
+
return np.clip(exg, 0, 1).astype(np.float32)
|
| 294 |
+
|
| 295 |
+
|
| 296 |
def compute_vegetation_mask(img):
|
| 297 |
"""
|
| 298 |
+
Identify vegetation pixels using three complementary indices:
|
| 299 |
+
1. Pseudo-NDVI (G-R)/(G+R)
|
| 300 |
+
2. Excess Green Index: ExG = 2G - R - B
|
| 301 |
+
3. HSV hue/saturation ranges
|
| 302 |
Returns a float map in [0, 1] where 1.0 = vegetation, 0.0 = non-vegetation.
|
| 303 |
"""
|
| 304 |
r = img[:, :, 0].astype(np.float32)
|
| 305 |
g = img[:, :, 1].astype(np.float32)
|
| 306 |
ndvi = (g - r) / (g + r + 1e-6)
|
| 307 |
|
| 308 |
+
exg = compute_excess_green(img)
|
| 309 |
+
|
| 310 |
hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)
|
| 311 |
hue = hsv[:, :, 0].astype(np.float32)
|
| 312 |
sat = hsv[:, :, 1].astype(np.float32)
|
| 313 |
|
| 314 |
ndvi_veg = (ndvi > 0.08).astype(np.float32)
|
| 315 |
+
exg_veg = (exg > 0.05).astype(np.float32)
|
| 316 |
hsv_veg = ((hue >= 35) & (hue <= 85) & (sat > 30)).astype(np.float32)
|
| 317 |
|
| 318 |
+
veg = np.clip(ndvi_veg * 0.4 + exg_veg * 0.3 + hsv_veg * 0.3, 0, 1)
|
| 319 |
veg = cv2.GaussianBlur(veg, (11, 11), 0)
|
| 320 |
return veg
|
| 321 |
|
|
|
|
| 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 |
+
q = 0.93 - (sens - 0.5) * 0.06
|
| 703 |
+
q = float(np.clip(q, 0.85, 0.96))
|
| 704 |
|
| 705 |
thr_score = float(np.quantile(fused_smooth, q))
|
| 706 |
change_mask = (fused_smooth >= thr_score).astype(np.uint8) * 255
|
|
|
|
| 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 |
+
model_mask = None
|
| 745 |
+
model_ok = False
|
| 746 |
|
| 747 |
if is_model_available():
|
| 748 |
threshold = 0.25 + (1.0 - sensitivity) * 0.25
|
| 749 |
try:
|
| 750 |
+
model_mask, score_map = predict_change_mask(
|
| 751 |
img1, img2, threshold=threshold)
|
| 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 |
+
# Always run rule-based fusion to catch vegetation/texture changes
|
| 758 |
+
rule_mask, core_debug = _ai_fusion_core(img1, img2, sensitivity=sensitivity)
|
| 759 |
+
|
| 760 |
+
if model_ok and model_mask is not None:
|
| 761 |
+
# Union: any pixel detected by either engine is kept
|
| 762 |
+
combined = np.maximum(model_mask, rule_mask)
|
| 763 |
+
combined = _clean_mask(combined, sensitivity=sensitivity)
|
| 764 |
+
debug = {
|
| 765 |
+
"method": "AI-Based Deep Learning (AdaptFormer + rule-based fusion)",
|
| 766 |
+
"model": "adaptformer-levir-cd",
|
| 767 |
+
"threshold_used": int(threshold * 255),
|
| 768 |
+
"sensitivity": float(sensitivity),
|
| 769 |
+
"model_changed_px": int(np.sum(model_mask > 127)),
|
| 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 (rule-based fallback)",
|
| 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):
|
|
|
|
| 819 |
return final_mask, debug
|
| 820 |
|
| 821 |
|
| 822 |
+
# ---------------------------------------------------------------------------
|
| 823 |
+
# 10b. Hybrid AI method (deep learning + classical with confidence map)
|
| 824 |
+
# ---------------------------------------------------------------------------
|
| 825 |
+
|
| 826 |
+
def _build_confidence_map_from_channels(img1, img2, dl_score=None):
|
| 827 |
+
"""
|
| 828 |
+
Build a per-pixel confidence map from multiple signal channels.
|
| 829 |
+
Includes color, SSIM, texture, edge, CVA, and optionally a DL score map.
|
| 830 |
+
Returns float32 map in [0,1].
|
| 831 |
+
"""
|
| 832 |
+
from .models.model_utils import build_confidence_map
|
| 833 |
+
|
| 834 |
+
color = compute_cva(img1, img2)
|
| 835 |
+
ssim = compute_ssim_change_map(img1, img2)
|
| 836 |
+
ssim_norm = ssim / (ssim.max() + 1e-8)
|
| 837 |
+
texture = compute_texture_change(img1, img2)
|
| 838 |
+
texture_norm = texture / (texture.max() + 1e-8)
|
| 839 |
+
edge = compute_edge_change(img1, img2)
|
| 840 |
+
|
| 841 |
+
channels = [color, ssim_norm.astype(np.float32), texture_norm.astype(np.float32), edge]
|
| 842 |
+
weights = [0.30, 0.25, 0.15, 0.10]
|
| 843 |
+
|
| 844 |
+
if dl_score is not None:
|
| 845 |
+
channels.append(dl_score)
|
| 846 |
+
weights.append(0.40)
|
| 847 |
+
# Re-normalize so weights sum to 1
|
| 848 |
+
total = sum(weights)
|
| 849 |
+
weights = [w / total for w in weights]
|
| 850 |
+
|
| 851 |
+
return build_confidence_map(channels, weights)
|
| 852 |
+
|
| 853 |
+
|
| 854 |
+
def _multiscale_classical(img1, img2, sensitivity=0.5):
|
| 855 |
+
"""Run classical fusion at multiple scales and OR-combine for better recall."""
|
| 856 |
+
from .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 |
+
thr = 0.25 + (1.0 - sensitivity) * 0.25
|
| 885 |
+
|
| 886 |
+
if is_model_available():
|
| 887 |
+
try:
|
| 888 |
+
dl_mask, dl_score = predict_change_mask(img1, img2, threshold=thr)
|
| 889 |
+
dl_method = "adaptformer"
|
| 890 |
+
except Exception:
|
| 891 |
+
pass
|
| 892 |
+
|
| 893 |
+
if dl_method == "none":
|
| 894 |
+
try:
|
| 895 |
+
from .models.change_model import is_siamese_available, predict_siamese
|
| 896 |
+
if is_siamese_available():
|
| 897 |
+
dl_mask, dl_score = predict_siamese(img1, img2, threshold=thr)
|
| 898 |
+
dl_method = "siamese_unet"
|
| 899 |
+
except Exception:
|
| 900 |
+
pass
|
| 901 |
+
|
| 902 |
+
# --- Multi-scale classical mask ---
|
| 903 |
+
classical_mask = _multiscale_classical(img1, img2, sensitivity=sensitivity)
|
| 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 |
+
fused = (dl_w * dl_mask.astype(np.float32) +
|
| 914 |
+
cl_w * classical_mask.astype(np.float32))
|
| 915 |
+
|
| 916 |
+
# Boost regions where confidence is high
|
| 917 |
+
if conf_map is not None:
|
| 918 |
+
conf_boost = np.clip(conf_map * 1.5, 0, 1)
|
| 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 |
+
debug = {
|
| 926 |
+
"method": f"Hybrid AI ({dl_method} + multi-scale classical)",
|
| 927 |
+
"dl_method": dl_method,
|
| 928 |
+
"threshold_used": fused_thr,
|
| 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 |
# ---------------------------------------------------------------------------
|
| 938 |
# 11. Robust post-processing
|
| 939 |
# ---------------------------------------------------------------------------
|
|
|
|
| 958 |
mask[:, :border_margin] = 0
|
| 959 |
mask[:, -border_margin:] = 0
|
| 960 |
|
| 961 |
+
mask = cv2.medianBlur(mask, 3)
|
| 962 |
|
| 963 |
+
open_size = max(3, int(4 * (1 - sensitivity * 0.5)))
|
| 964 |
if open_size % 2 == 0:
|
| 965 |
open_size += 1
|
| 966 |
k_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (open_size, open_size))
|
| 967 |
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k_open)
|
| 968 |
|
| 969 |
+
close_size = max(3, int(5 * (1 - sensitivity)))
|
| 970 |
if close_size % 2 == 0:
|
| 971 |
close_size += 1
|
| 972 |
k_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (close_size, close_size))
|
|
|
|
| 1139 |
|
| 1140 |
# Vegetation indices
|
| 1141 |
ndvi = (mean_rgb[1] - mean_rgb[0]) / (mean_rgb[1] + mean_rgb[0] + 1e-6)
|
| 1142 |
+
exg = float(np.mean(compute_excess_green(region)))
|
| 1143 |
|
| 1144 |
# Texture
|
| 1145 |
texture_std = float(np.std(gray))
|
|
|
|
| 1165 |
|
| 1166 |
return {
|
| 1167 |
"mean_rgb": mean_rgb, "std_rgb": std_rgb, "mean_hsv": mean_hsv, "mean_lab": mean_lab,
|
| 1168 |
+
"ndvi": ndvi, "exg": exg,
|
| 1169 |
+
"texture_std": texture_std, "lbp_variance": lbp_variance,
|
| 1170 |
"edge_density": edge_density, "orientation_entropy": orientation_entropy,
|
| 1171 |
"glcm_contrast": glcm_contrast,
|
| 1172 |
"color_homogeneity": float(np.mean(std_rgb)),
|
|
|
|
| 1288 |
return {
|
| 1289 |
"before": feat_b, "after": feat_a,
|
| 1290 |
"delta_ndvi": feat_a["ndvi"] - feat_b["ndvi"],
|
| 1291 |
+
"delta_exg": feat_a["exg"] - feat_b["exg"],
|
| 1292 |
"delta_green_ratio": feat_a["green_ratio"] - feat_b["green_ratio"],
|
| 1293 |
"delta_edge_density": feat_a["edge_density"] - feat_b["edge_density"],
|
| 1294 |
"delta_brightness": feat_a["brightness"] - feat_b["brightness"],
|
|
|
|
| 1371 |
if diff:
|
| 1372 |
# Differential: detect actual vegetation gain or loss
|
| 1373 |
if abs(diff["delta_ndvi"]) > 0.08:
|
| 1374 |
+
veg += 0.25
|
| 1375 |
+
# Excess Green Index delta — best single indicator of vegetation change
|
| 1376 |
+
if abs(diff.get("delta_exg", 0)) > 0.04:
|
| 1377 |
veg += 0.20
|
| 1378 |
+
elif abs(diff.get("delta_exg", 0)) > 0.02:
|
| 1379 |
+
veg += 0.10
|
| 1380 |
+
if abs(diff["delta_green_ratio"]) > 0.04:
|
| 1381 |
+
veg += 0.15
|
| 1382 |
if diff["lab_color_distance"] > 15 and (
|
| 1383 |
diff["before"]["ndvi"] > 0.05 or diff["after"]["ndvi"] > 0.05):
|
| 1384 |
+
veg += 0.12
|
| 1385 |
if abs(diff["delta_saturation"]) > 15 and (
|
| 1386 |
diff["before"]["green_ratio"] > 0.34 or diff["after"]["green_ratio"] > 0.34):
|
| 1387 |
+
veg += 0.12
|
| 1388 |
if diff["delta_lines"] < 3 and diff["delta_corners"] < 5:
|
| 1389 |
+
veg += 0.06
|
| 1390 |
if area > 500:
|
| 1391 |
veg += 0.04
|
| 1392 |
else:
|
|
|
|
| 1585 |
road += 0.03
|
| 1586 |
scores["Road/Pavement Change"] = road
|
| 1587 |
|
| 1588 |
+
# ---- Temporary Structure (sheds, tents, makeshift) ----
|
| 1589 |
+
tmp = 0.0
|
| 1590 |
+
if diff:
|
| 1591 |
+
ded_t = diff["delta_edge_density"]
|
| 1592 |
+
if 3 < ded_t < 20:
|
| 1593 |
+
tmp += 0.16
|
| 1594 |
+
if diff["delta_lines"] > 0 and diff["delta_lines"] <= 5:
|
| 1595 |
+
tmp += 0.12
|
| 1596 |
+
if diff["delta_corners"] > 0 and diff["delta_corners"] <= 6:
|
| 1597 |
+
tmp += 0.10
|
| 1598 |
+
if diff["hull_ratio_after"] < 0.50:
|
| 1599 |
+
tmp += 0.10
|
| 1600 |
+
if diff["after"]["ndvi"] < 0.08:
|
| 1601 |
+
tmp += 0.08
|
| 1602 |
+
ssim_t = diff.get("ssim", 1.0)
|
| 1603 |
+
if 0.3 < ssim_t < 0.7:
|
| 1604 |
+
tmp += 0.10
|
| 1605 |
+
if diff["lab_color_distance"] > 10:
|
| 1606 |
+
tmp += 0.08
|
| 1607 |
+
if 200 <= area <= 5000:
|
| 1608 |
+
tmp += 0.08
|
| 1609 |
+
if 1.0 <= aspect_ratio <= 3.5:
|
| 1610 |
+
tmp += 0.06
|
| 1611 |
+
else:
|
| 1612 |
+
if feat_a["edge_density"] > 15 and feat_a["edge_density"] < 50:
|
| 1613 |
+
tmp += 0.18
|
| 1614 |
+
if feat_a["orientation_entropy"] > 2.0:
|
| 1615 |
+
tmp += 0.12
|
| 1616 |
+
if feat_a["color_homogeneity"] > 20:
|
| 1617 |
+
tmp += 0.10
|
| 1618 |
+
if feat_a["ndvi"] < 0.08:
|
| 1619 |
+
tmp += 0.12
|
| 1620 |
+
if 200 <= area <= 5000:
|
| 1621 |
+
tmp += 0.10
|
| 1622 |
+
if 1.0 <= aspect_ratio <= 3.5:
|
| 1623 |
+
tmp += 0.08
|
| 1624 |
+
if feat_a["saturation"] < 100:
|
| 1625 |
+
tmp += 0.06
|
| 1626 |
+
scores["Temporary Structure"] = tmp
|
| 1627 |
+
|
| 1628 |
# ---- Bare Land/Soil Change ----
|
| 1629 |
soil = 0.0
|
| 1630 |
if feat_a["red_ratio"] > 0.34 and feat_a["green_ratio"] < 0.36:
|
|
|
|
| 1646 |
best = max(scores, key=scores.get)
|
| 1647 |
conf = scores[best]
|
| 1648 |
|
| 1649 |
+
if conf < 0.22:
|
| 1650 |
return "Unclassified", conf
|
| 1651 |
return best, min(conf, 1.0)
|
| 1652 |
|
|
|
|
| 1842 |
# ---------------------------------------------------------------------------
|
| 1843 |
|
| 1844 |
_STRUCTURAL_TYPES = {"New Construction/Building", "Demolition/Clearing",
|
| 1845 |
+
"Road/Pavement Change", "Temporary Structure"}
|
| 1846 |
|
| 1847 |
|
| 1848 |
def _region_has_structure(crop):
|
|
|
|
| 2463 |
"note": "KMeans clustering path does not use binary threshold.",
|
| 2464 |
"sensitivity": float(detection_sensitivity),
|
| 2465 |
}
|
| 2466 |
+
elif method == "Hybrid AI":
|
| 2467 |
+
change_mask, threshold_debug = hybrid_ai_method(
|
| 2468 |
+
before_array, after_array, sensitivity=detection_sensitivity
|
| 2469 |
+
)
|
| 2470 |
else:
|
| 2471 |
change_mask, threshold_debug = hybrid_method(
|
| 2472 |
before_array, after_array, sensitivity=detection_sensitivity
|
|
|
|
| 2480 |
changed_pixels_ratio = float(np.sum(change_mask > 127)) / float(total_pixels) if total_pixels else 0.0
|
| 2481 |
|
| 2482 |
used_fallback = False
|
| 2483 |
+
if method in ("AI-Based Deep Learning", "Hybrid Approach", "Hybrid AI") and changed_pixels_ratio < 0.0025:
|
| 2484 |
diff_mask, diff_debug = image_difference_method(
|
| 2485 |
before_array, after_array, sensitivity=detection_sensitivity
|
| 2486 |
)
|
app/models/__init__.py
ADDED
|
File without changes
|
app/models/change_model.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Siamese U-Net for satellite change detection.
|
| 3 |
+
|
| 4 |
+
A lightweight Siamese encoder shares weights between before/after images,
|
| 5 |
+
fuses features via concatenation + difference, and decodes into a binary
|
| 6 |
+
change probability map.
|
| 7 |
+
|
| 8 |
+
Designed for CPU inference (< 2s per 256x256 tile).
|
| 9 |
+
"""
|
| 10 |
+
import logging
|
| 11 |
+
import os
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
|
| 14 |
+
import cv2
|
| 15 |
+
import numpy as np
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
_MODEL = None
|
| 20 |
+
_DEVICE = None
|
| 21 |
+
_AVAILABLE = None
|
| 22 |
+
_WEIGHTS_DIR = Path(__file__).parent / "weights"
|
| 23 |
+
_WEIGHTS_FILE = _WEIGHTS_DIR / "siamese_unet_cd.pt"
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _try_torch():
|
| 27 |
+
try:
|
| 28 |
+
import torch
|
| 29 |
+
import torch.nn as nn
|
| 30 |
+
return torch, nn
|
| 31 |
+
except ImportError:
|
| 32 |
+
return None, None
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
# ---------------------------------------------------------------------------
|
| 36 |
+
# Model architecture
|
| 37 |
+
# ---------------------------------------------------------------------------
|
| 38 |
+
|
| 39 |
+
def _build_model():
|
| 40 |
+
torch, nn = _try_torch()
|
| 41 |
+
if torch is None:
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
class ConvBlock(nn.Module):
|
| 45 |
+
def __init__(self, in_ch, out_ch):
|
| 46 |
+
super().__init__()
|
| 47 |
+
self.block = nn.Sequential(
|
| 48 |
+
nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False),
|
| 49 |
+
nn.BatchNorm2d(out_ch),
|
| 50 |
+
nn.ReLU(inplace=True),
|
| 51 |
+
nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False),
|
| 52 |
+
nn.BatchNorm2d(out_ch),
|
| 53 |
+
nn.ReLU(inplace=True),
|
| 54 |
+
)
|
| 55 |
+
def forward(self, x):
|
| 56 |
+
return self.block(x)
|
| 57 |
+
|
| 58 |
+
class Encoder(nn.Module):
|
| 59 |
+
def __init__(self, in_ch=3, base=32):
|
| 60 |
+
super().__init__()
|
| 61 |
+
self.enc1 = ConvBlock(in_ch, base)
|
| 62 |
+
self.enc2 = ConvBlock(base, base * 2)
|
| 63 |
+
self.enc3 = ConvBlock(base * 2, base * 4)
|
| 64 |
+
self.enc4 = ConvBlock(base * 4, base * 8)
|
| 65 |
+
self.pool = nn.MaxPool2d(2)
|
| 66 |
+
|
| 67 |
+
def forward(self, x):
|
| 68 |
+
e1 = self.enc1(x)
|
| 69 |
+
e2 = self.enc2(self.pool(e1))
|
| 70 |
+
e3 = self.enc3(self.pool(e2))
|
| 71 |
+
e4 = self.enc4(self.pool(e3))
|
| 72 |
+
return [e1, e2, e3, e4]
|
| 73 |
+
|
| 74 |
+
class SiameseUNet(nn.Module):
|
| 75 |
+
"""
|
| 76 |
+
Siamese U-Net: shared encoder processes before/after images independently.
|
| 77 |
+
Decoder fuses features via concatenation of both streams + their absolute
|
| 78 |
+
difference, providing the decoder with explicit change information.
|
| 79 |
+
"""
|
| 80 |
+
def __init__(self, in_ch=3, base=32, out_ch=2):
|
| 81 |
+
super().__init__()
|
| 82 |
+
self.encoder = Encoder(in_ch, base)
|
| 83 |
+
b = base
|
| 84 |
+
|
| 85 |
+
# Decoder: at each level receives [enc_a, enc_b, |enc_a-enc_b|] = 3x channels
|
| 86 |
+
self.up4 = nn.ConvTranspose2d(b * 8, b * 4, 2, stride=2)
|
| 87 |
+
self.dec4 = ConvBlock(b * 4 + b * 4 * 3, b * 4)
|
| 88 |
+
|
| 89 |
+
self.up3 = nn.ConvTranspose2d(b * 4, b * 2, 2, stride=2)
|
| 90 |
+
self.dec3 = ConvBlock(b * 2 + b * 2 * 3, b * 2)
|
| 91 |
+
|
| 92 |
+
self.up2 = nn.ConvTranspose2d(b * 2, b, 2, stride=2)
|
| 93 |
+
self.dec2 = ConvBlock(b + b * 3, b)
|
| 94 |
+
|
| 95 |
+
self.head = nn.Conv2d(b, out_ch, 1)
|
| 96 |
+
|
| 97 |
+
def forward(self, img_a, img_b):
|
| 98 |
+
feats_a = self.encoder(img_a)
|
| 99 |
+
feats_b = self.encoder(img_b)
|
| 100 |
+
|
| 101 |
+
# Bottleneck: fuse deepest features
|
| 102 |
+
bot = torch.cat([feats_a[3], feats_b[3], torch.abs(feats_a[3] - feats_b[3])], dim=1)
|
| 103 |
+
|
| 104 |
+
import torch.nn.functional as F
|
| 105 |
+
# Level 3
|
| 106 |
+
d4 = self.up4(feats_a[3])
|
| 107 |
+
skip3 = torch.cat([feats_a[2], feats_b[2], torch.abs(feats_a[2] - feats_b[2])], dim=1)
|
| 108 |
+
d4 = self.dec4(torch.cat([d4, skip3], dim=1))
|
| 109 |
+
|
| 110 |
+
# Level 2
|
| 111 |
+
d3 = self.up3(d4)
|
| 112 |
+
skip2 = torch.cat([feats_a[1], feats_b[1], torch.abs(feats_a[1] - feats_b[1])], dim=1)
|
| 113 |
+
d3 = self.dec3(torch.cat([d3, skip2], dim=1))
|
| 114 |
+
|
| 115 |
+
# Level 1
|
| 116 |
+
d2 = self.up2(d3)
|
| 117 |
+
skip1 = torch.cat([feats_a[0], feats_b[0], torch.abs(feats_a[0] - feats_b[0])], dim=1)
|
| 118 |
+
d2 = self.dec2(torch.cat([d2, skip1], dim=1))
|
| 119 |
+
|
| 120 |
+
return self.head(d2)
|
| 121 |
+
|
| 122 |
+
return SiameseUNet
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
# ---------------------------------------------------------------------------
|
| 126 |
+
# Model loading (singleton)
|
| 127 |
+
# ---------------------------------------------------------------------------
|
| 128 |
+
|
| 129 |
+
def is_siamese_available():
|
| 130 |
+
"""Check if PyTorch is installed and model can be constructed."""
|
| 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 |
+
|
| 139 |
+
def _load_siamese():
|
| 140 |
+
global _MODEL, _DEVICE
|
| 141 |
+
if _MODEL is not None:
|
| 142 |
+
return _MODEL
|
| 143 |
+
|
| 144 |
+
torch, _ = _try_torch()
|
| 145 |
+
if torch is None:
|
| 146 |
+
raise RuntimeError("PyTorch not installed")
|
| 147 |
+
|
| 148 |
+
_DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 149 |
+
|
| 150 |
+
ModelClass = _build_model()
|
| 151 |
+
model = ModelClass(in_ch=3, base=32, out_ch=2)
|
| 152 |
+
|
| 153 |
+
if _WEIGHTS_FILE.exists():
|
| 154 |
+
logger.info("Loading Siamese U-Net weights from %s", _WEIGHTS_FILE)
|
| 155 |
+
state = torch.load(str(_WEIGHTS_FILE), map_location=_DEVICE, weights_only=True)
|
| 156 |
+
model.load_state_dict(state)
|
| 157 |
+
else:
|
| 158 |
+
logger.info("No pretrained weights found at %s — using random init "
|
| 159 |
+
"(model will still produce change maps but accuracy depends on "
|
| 160 |
+
"classical fusion weighting)", _WEIGHTS_FILE)
|
| 161 |
+
|
| 162 |
+
model.to(_DEVICE)
|
| 163 |
+
model.eval()
|
| 164 |
+
_MODEL = model
|
| 165 |
+
return _MODEL
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ---------------------------------------------------------------------------
|
| 169 |
+
# Inference
|
| 170 |
+
# ---------------------------------------------------------------------------
|
| 171 |
+
|
| 172 |
+
_TILE = 256
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
def predict_siamese(img1, img2, threshold=0.5):
|
| 176 |
+
"""
|
| 177 |
+
Run Siamese U-Net inference on two RGB uint8 arrays.
|
| 178 |
+
Tile-based with overlap stitching (same pattern as AdaptFormer).
|
| 179 |
+
Returns (uint8 mask [0|255], float32 probability map [0-1]).
|
| 180 |
+
"""
|
| 181 |
+
torch, _ = _try_torch()
|
| 182 |
+
model = _load_siamese()
|
| 183 |
+
|
| 184 |
+
if img1.shape != img2.shape:
|
| 185 |
+
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 186 |
+
|
| 187 |
+
h, w = img1.shape[:2]
|
| 188 |
+
tile = _TILE
|
| 189 |
+
overlap = tile // 4
|
| 190 |
+
stride = tile - overlap
|
| 191 |
+
|
| 192 |
+
pad_h = (tile - h % tile) % tile
|
| 193 |
+
pad_w = (tile - w % tile) % tile
|
| 194 |
+
if pad_h or pad_w:
|
| 195 |
+
img1 = np.pad(img1, ((0, pad_h), (0, pad_w), (0, 0)), mode="reflect")
|
| 196 |
+
img2 = np.pad(img2, ((0, pad_h), (0, pad_w), (0, 0)), mode="reflect")
|
| 197 |
+
|
| 198 |
+
ph, pw = img1.shape[:2]
|
| 199 |
+
score_sum = np.zeros((ph, pw), dtype=np.float32)
|
| 200 |
+
count = np.zeros((ph, pw), dtype=np.float32)
|
| 201 |
+
|
| 202 |
+
ramp = np.linspace(0, 1, overlap)
|
| 203 |
+
flat = np.ones(tile - 2 * overlap)
|
| 204 |
+
profile = np.concatenate([ramp, flat, ramp[::-1]])
|
| 205 |
+
weight_2d = np.outer(profile, profile).astype(np.float32)
|
| 206 |
+
|
| 207 |
+
mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
|
| 208 |
+
std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
|
| 209 |
+
|
| 210 |
+
with torch.no_grad():
|
| 211 |
+
for y0 in range(0, ph - tile + 1, stride):
|
| 212 |
+
for x0 in range(0, pw - tile + 1, stride):
|
| 213 |
+
t1 = img1[y0:y0+tile, x0:x0+tile].astype(np.float32) / 255.0
|
| 214 |
+
t2 = img2[y0:y0+tile, x0:x0+tile].astype(np.float32) / 255.0
|
| 215 |
+
|
| 216 |
+
t1 = (t1 - mean) / std
|
| 217 |
+
t2 = (t2 - mean) / std
|
| 218 |
+
|
| 219 |
+
ta = torch.from_numpy(t1.transpose(2, 0, 1)).unsqueeze(0).to(_DEVICE)
|
| 220 |
+
tb = torch.from_numpy(t2.transpose(2, 0, 1)).unsqueeze(0).to(_DEVICE)
|
| 221 |
+
|
| 222 |
+
logits = model(ta, tb)
|
| 223 |
+
probs = torch.softmax(logits, dim=1)
|
| 224 |
+
prob_map = probs[0, 1].cpu().numpy()
|
| 225 |
+
|
| 226 |
+
if prob_map.shape != (tile, tile):
|
| 227 |
+
prob_map = cv2.resize(prob_map, (tile, tile))
|
| 228 |
+
|
| 229 |
+
score_sum[y0:y0+tile, x0:x0+tile] += prob_map * weight_2d
|
| 230 |
+
count[y0:y0+tile, x0:x0+tile] += weight_2d
|
| 231 |
+
|
| 232 |
+
count = np.maximum(count, 1e-6)
|
| 233 |
+
avg = score_sum / count
|
| 234 |
+
avg = avg[:h, :w]
|
| 235 |
+
|
| 236 |
+
mask = (avg >= threshold).astype(np.uint8) * 255
|
| 237 |
+
return mask, avg
|
app/models/model_utils.py
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared utilities for model loading, tile processing, and multi-scale detection.
|
| 3 |
+
"""
|
| 4 |
+
import cv2
|
| 5 |
+
import numpy as np
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def split_into_tiles(img, tile_size=512, overlap=64):
|
| 9 |
+
"""
|
| 10 |
+
Split an image into overlapping tiles.
|
| 11 |
+
Returns list of (tile, y_offset, x_offset) tuples.
|
| 12 |
+
"""
|
| 13 |
+
h, w = img.shape[:2]
|
| 14 |
+
stride = tile_size - overlap
|
| 15 |
+
tiles = []
|
| 16 |
+
|
| 17 |
+
pad_h = (tile_size - h % tile_size) % tile_size if h % tile_size else 0
|
| 18 |
+
pad_w = (tile_size - w % tile_size) % tile_size if w % tile_size else 0
|
| 19 |
+
if pad_h or pad_w:
|
| 20 |
+
img = np.pad(img, ((0, pad_h), (0, pad_w), (0, 0)) if img.ndim == 3
|
| 21 |
+
else ((0, pad_h), (0, pad_w)), mode="reflect")
|
| 22 |
+
|
| 23 |
+
ph, pw = img.shape[:2]
|
| 24 |
+
for y in range(0, ph - tile_size + 1, stride):
|
| 25 |
+
for x in range(0, pw - tile_size + 1, stride):
|
| 26 |
+
tile = img[y:y+tile_size, x:x+tile_size]
|
| 27 |
+
tiles.append((tile, y, x))
|
| 28 |
+
|
| 29 |
+
return tiles, (ph, pw), (h, w)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def merge_tile_masks(tile_results, padded_shape, orig_shape, tile_size=512, overlap=64):
|
| 33 |
+
"""
|
| 34 |
+
Merge tile-level binary masks back into a single full-resolution mask.
|
| 35 |
+
Uses raised-cosine blending to avoid tile boundary artifacts.
|
| 36 |
+
"""
|
| 37 |
+
ph, pw = padded_shape
|
| 38 |
+
h, w = orig_shape
|
| 39 |
+
|
| 40 |
+
score_sum = np.zeros((ph, pw), dtype=np.float32)
|
| 41 |
+
count = np.zeros((ph, pw), dtype=np.float32)
|
| 42 |
+
|
| 43 |
+
ramp = np.linspace(0, 1, overlap)
|
| 44 |
+
flat = np.ones(tile_size - 2 * overlap)
|
| 45 |
+
profile = np.concatenate([ramp, flat, ramp[::-1]])
|
| 46 |
+
weight_2d = np.outer(profile, profile).astype(np.float32)
|
| 47 |
+
|
| 48 |
+
for (mask_tile, y, x) in tile_results:
|
| 49 |
+
score = mask_tile.astype(np.float32) / 255.0 if mask_tile.max() > 1 else mask_tile.astype(np.float32)
|
| 50 |
+
if score.shape != (tile_size, tile_size):
|
| 51 |
+
score = cv2.resize(score, (tile_size, tile_size))
|
| 52 |
+
score_sum[y:y+tile_size, x:x+tile_size] += score * weight_2d
|
| 53 |
+
count[y:y+tile_size, x:x+tile_size] += weight_2d
|
| 54 |
+
|
| 55 |
+
count = np.maximum(count, 1e-6)
|
| 56 |
+
merged = score_sum / count
|
| 57 |
+
merged = merged[:h, :w]
|
| 58 |
+
return (merged * 255).astype(np.uint8)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def multiscale_detect(detect_fn, img1, img2, scales=(1.0, 0.5, 0.25)):
|
| 62 |
+
"""
|
| 63 |
+
Run a detection function at multiple scales and combine via logical OR.
|
| 64 |
+
detect_fn(img1, img2) -> uint8 mask [0|255].
|
| 65 |
+
Captures small structures at full res and large regions at coarse scales.
|
| 66 |
+
"""
|
| 67 |
+
h, w = img1.shape[:2]
|
| 68 |
+
combined = np.zeros((h, w), dtype=np.uint8)
|
| 69 |
+
|
| 70 |
+
for scale in scales:
|
| 71 |
+
if scale == 1.0:
|
| 72 |
+
s1, s2 = img1, img2
|
| 73 |
+
else:
|
| 74 |
+
sh, sw = max(64, int(h * scale)), max(64, int(w * scale))
|
| 75 |
+
s1 = cv2.resize(img1, (sw, sh), interpolation=cv2.INTER_AREA)
|
| 76 |
+
s2 = cv2.resize(img2, (sw, sh), interpolation=cv2.INTER_AREA)
|
| 77 |
+
|
| 78 |
+
mask = detect_fn(s1, s2)
|
| 79 |
+
|
| 80 |
+
if scale != 1.0:
|
| 81 |
+
mask = cv2.resize(mask, (w, h), interpolation=cv2.INTER_NEAREST)
|
| 82 |
+
|
| 83 |
+
combined = np.maximum(combined, mask)
|
| 84 |
+
|
| 85 |
+
return combined
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def build_confidence_map(channels, weights=None):
|
| 89 |
+
"""
|
| 90 |
+
Build a [0-1] confidence map from multiple normalized signal channels.
|
| 91 |
+
Each channel should be a float32 array in [0,1].
|
| 92 |
+
If weights is None, uses equal weighting.
|
| 93 |
+
"""
|
| 94 |
+
if not channels:
|
| 95 |
+
return None
|
| 96 |
+
if weights is None:
|
| 97 |
+
weights = [1.0 / len(channels)] * len(channels)
|
| 98 |
+
total_w = sum(weights)
|
| 99 |
+
weights = [w / total_w for w in weights]
|
| 100 |
+
|
| 101 |
+
shape = channels[0].shape
|
| 102 |
+
conf = np.zeros(shape, dtype=np.float64)
|
| 103 |
+
for ch, w in zip(channels, weights):
|
| 104 |
+
if ch.shape != shape:
|
| 105 |
+
ch = cv2.resize(ch.astype(np.float32), (shape[1], shape[0]))
|
| 106 |
+
conf += w * ch.astype(np.float64)
|
| 107 |
+
|
| 108 |
+
return np.clip(conf, 0, 1).astype(np.float32)
|
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">
|
|
@@ -210,6 +210,7 @@
|
|
| 210 |
<option value="Image Difference">Image Difference</option>
|
| 211 |
<option value="Feature-Based">Feature-Based</option>
|
| 212 |
<option value="Hybrid Approach">Hybrid Approach</option>
|
|
|
|
| 213 |
</select>
|
| 214 |
</div>
|
| 215 |
<div class="form-group checkbox-group">
|
|
@@ -360,6 +361,6 @@
|
|
| 360 |
</div>
|
| 361 |
</div>
|
| 362 |
|
| 363 |
-
<script src="/static/js/app.js?v=
|
| 364 |
</body>
|
| 365 |
</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=25" />
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div class="app">
|
|
|
|
| 210 |
<option value="Image Difference">Image Difference</option>
|
| 211 |
<option value="Feature-Based">Feature-Based</option>
|
| 212 |
<option value="Hybrid Approach">Hybrid Approach</option>
|
| 213 |
+
<option value="Hybrid AI">Hybrid AI (DL + Multi-Scale)</option>
|
| 214 |
</select>
|
| 215 |
</div>
|
| 216 |
<div class="form-group checkbox-group">
|
|
|
|
| 361 |
</div>
|
| 362 |
</div>
|
| 363 |
|
| 364 |
+
<script src="/static/js/app.js?v=40"></script>
|
| 365 |
</body>
|
| 366 |
</html>
|