Spaces:
Sleeping
Sleeping
Commit ·
debf6fe
1
Parent(s): 8beea23
Redesign detection pipeline: CVA, vegetation/shadow suppression, SNR fusion, multi-scale SSIM
Browse files- app/detection_engine.py +216 -77
app/detection_engine.py
CHANGED
|
@@ -1,7 +1,8 @@
|
|
| 1 |
"""
|
| 2 |
-
Satellite Change Detection Engine
|
| 3 |
-
High-accuracy detection with multi-channel analysis, SSIM, texture features,
|
| 4 |
-
adaptive thresholding,
|
|
|
|
| 5 |
"""
|
| 6 |
import numpy as np
|
| 7 |
import cv2
|
|
@@ -16,7 +17,7 @@ from collections import Counter
|
|
| 16 |
# ---------------------------------------------------------------------------
|
| 17 |
|
| 18 |
def preprocess_image(image):
|
| 19 |
-
"""Preprocess image: convert to RGB, limit size."""
|
| 20 |
img_array = np.array(image)
|
| 21 |
if img_array.ndim == 2:
|
| 22 |
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
|
|
@@ -30,6 +31,8 @@ def preprocess_image(image):
|
|
| 30 |
scale = max_size / max(height, width)
|
| 31 |
new_w, new_h = max(1, int(width * scale)), max(1, int(height * scale))
|
| 32 |
img_array = cv2.resize(img_array, (new_w, new_h), interpolation=cv2.INTER_AREA)
|
|
|
|
|
|
|
| 33 |
return img_array
|
| 34 |
|
| 35 |
|
|
@@ -148,39 +151,153 @@ def normalize_radiometry(img1, img2):
|
|
| 148 |
|
| 149 |
|
| 150 |
# ---------------------------------------------------------------------------
|
| 151 |
-
# 4.
|
| 152 |
# ---------------------------------------------------------------------------
|
| 153 |
|
| 154 |
-
def
|
| 155 |
-
"""
|
| 156 |
-
|
| 157 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
C1 = (0.01 * 255) ** 2
|
| 160 |
C2 = (0.03 * 255) ** 2
|
| 161 |
|
| 162 |
-
mu1 = cv2.GaussianBlur(gray1, (win_size, win_size),
|
| 163 |
-
mu2 = cv2.GaussianBlur(gray2, (win_size, win_size),
|
| 164 |
|
| 165 |
mu1_sq = mu1 * mu1
|
| 166 |
mu2_sq = mu2 * mu2
|
| 167 |
mu1_mu2 = mu1 * mu2
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|
| 171 |
-
|
| 172 |
-
sigma12 = cv2.GaussianBlur(gray1 * gray2, (win_size, win_size), 1.5) - mu1_mu2
|
| 173 |
|
| 174 |
denom = (mu1_sq + mu2_sq + C1) * (sigma1_sq + sigma2_sq + C2)
|
| 175 |
ssim_map = ((2 * mu1_mu2 + C1) * (2 * sigma12 + C2)) / (denom + 1e-12)
|
| 176 |
-
|
| 177 |
-
# Structural dissimilarity: 0 = identical, 1 = completely different
|
| 178 |
dssim = np.clip((1.0 - ssim_map) / 2.0, 0, 1)
|
| 179 |
return dssim
|
| 180 |
|
| 181 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
# ---------------------------------------------------------------------------
|
| 183 |
-
#
|
| 184 |
# ---------------------------------------------------------------------------
|
| 185 |
|
| 186 |
def compute_lbp(gray, radius=1, n_points=8):
|
|
@@ -207,7 +324,7 @@ def compute_texture_change(img1, img2):
|
|
| 207 |
|
| 208 |
|
| 209 |
# ---------------------------------------------------------------------------
|
| 210 |
-
#
|
| 211 |
# ---------------------------------------------------------------------------
|
| 212 |
|
| 213 |
def compute_edge_change(img1, img2):
|
|
@@ -232,7 +349,7 @@ def compute_edge_change(img1, img2):
|
|
| 232 |
|
| 233 |
|
| 234 |
# ---------------------------------------------------------------------------
|
| 235 |
-
#
|
| 236 |
# ---------------------------------------------------------------------------
|
| 237 |
|
| 238 |
def _adaptive_binary_threshold(score_uint8, min_floor=25, sensitivity=0.5):
|
|
@@ -348,8 +465,25 @@ def feature_based_method(img1, img2, num_clusters=4, sensitivity=0.5):
|
|
| 348 |
return change_mask
|
| 349 |
|
| 350 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 351 |
def _ai_fusion_core(img1, img2, sensitivity=0.5):
|
| 352 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 353 |
if img1.shape != img2.shape:
|
| 354 |
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 355 |
|
|
@@ -366,7 +500,6 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5):
|
|
| 366 |
else:
|
| 367 |
s1, s2 = lab1, lab2
|
| 368 |
diff = s1 - s2
|
| 369 |
-
# Delta-E (CIE76) normalized
|
| 370 |
delta_e = np.sqrt((diff[:, :, 0] / 100.0) ** 2 +
|
| 371 |
(diff[:, :, 1] / 128.0) ** 2 +
|
| 372 |
(diff[:, :, 2] / 128.0) ** 2)
|
|
@@ -388,52 +521,44 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5):
|
|
| 388 |
# ---- Channel 4: Edge change ----
|
| 389 |
edge_change = compute_edge_change(img1, img2)
|
| 390 |
|
| 391 |
-
# ----
|
| 392 |
-
|
| 393 |
-
|
| 394 |
-
|
| 395 |
-
|
| 396 |
-
|
| 397 |
-
hist = cv2.calcHist([ch_uint8], [0], None, [256], [0, 256]).flatten()
|
| 398 |
-
hist = hist / (hist.sum() + 1e-8)
|
| 399 |
-
entropy = -np.sum(hist[hist > 0] * np.log2(hist[hist > 0] + 1e-10))
|
| 400 |
-
weights.append(entropy)
|
| 401 |
-
|
| 402 |
-
# Normalize weights
|
| 403 |
total_w = sum(weights) + 1e-8
|
| 404 |
weights = [w / total_w for w in weights]
|
| 405 |
|
| 406 |
-
# Fuse
|
| 407 |
fused = np.zeros_like(color_change, dtype=np.float64)
|
| 408 |
for ch, w in zip(channels, weights):
|
| 409 |
fused += w * ch.astype(np.float64)
|
| 410 |
|
| 411 |
-
#
|
| 412 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 413 |
p995 = float(np.quantile(fused, 0.995))
|
| 414 |
if p995 <= 1e-8:
|
| 415 |
p995 = float(fused.max() + 1e-8)
|
| 416 |
fused_norm = np.clip(fused / (p995 + 1e-8), 0.0, 1.0)
|
| 417 |
|
| 418 |
-
# Gamma < 1 boosts mid-range responses (useful for subtle changes).
|
| 419 |
gamma = 0.85
|
| 420 |
fused_norm = np.power(fused_norm, gamma)
|
| 421 |
|
| 422 |
-
# Smooth before thresholding so genuine change forms connected regions
|
| 423 |
-
# (prevents _clean_mask from deleting thin speckle artifacts).
|
| 424 |
fused_smooth = cv2.GaussianBlur(fused_norm.astype(np.float32), (7, 7), 0)
|
| 425 |
|
| 426 |
-
# Sensitivity -> lower percentile => more detections.
|
| 427 |
sens = float(np.clip(sensitivity, 0.0, 1.0))
|
| 428 |
-
q = 0.
|
| 429 |
-
q = float(np.clip(q, 0.
|
| 430 |
|
| 431 |
thr_score = float(np.quantile(fused_smooth, q))
|
| 432 |
change_mask = (fused_smooth >= thr_score).astype(np.uint8) * 255
|
| 433 |
|
| 434 |
change_mask = _clean_mask(change_mask, sensitivity=sens)
|
| 435 |
|
| 436 |
-
# Bilateral filter preserves sharp boundaries while smoothing noise
|
| 437 |
change_mask = cv2.bilateralFilter(change_mask, 9, 75, 75)
|
| 438 |
_, change_mask = cv2.threshold(change_mask, 127, 255, cv2.THRESH_BINARY)
|
| 439 |
|
|
@@ -446,32 +571,32 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5):
|
|
| 446 |
"fused_p99": float(np.quantile(fused_smooth, 0.99)),
|
| 447 |
"fused_mean": float(np.mean(fused_smooth)),
|
| 448 |
"sensitivity": float(sensitivity),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
}
|
| 450 |
return change_mask, debug
|
| 451 |
|
| 452 |
|
| 453 |
def ai_deep_learning_method(img1, img2, sensitivity=0.5):
|
| 454 |
"""
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
This improves stability for asymmetric scenes / normalization drift.
|
| 459 |
"""
|
| 460 |
-
|
| 461 |
-
rev_mask, rev_debug = _ai_fusion_core(img2, img1, sensitivity=sensitivity)
|
| 462 |
-
|
| 463 |
-
combined = cv2.bitwise_or(fwd_mask, rev_mask)
|
| 464 |
-
combined = _clean_mask(combined, sensitivity=sensitivity)
|
| 465 |
|
| 466 |
debug = {
|
| 467 |
"method": "AI-Based Deep Learning",
|
| 468 |
-
"threshold_used":
|
| 469 |
-
"bidirectional": True,
|
| 470 |
-
"forward": fwd_debug,
|
| 471 |
-
"reverse": rev_debug,
|
| 472 |
"sensitivity": float(sensitivity),
|
|
|
|
| 473 |
}
|
| 474 |
-
return
|
| 475 |
|
| 476 |
|
| 477 |
def hybrid_method(img1, img2, sensitivity=0.5):
|
|
@@ -513,7 +638,7 @@ def hybrid_method(img1, img2, sensitivity=0.5):
|
|
| 513 |
|
| 514 |
|
| 515 |
# ---------------------------------------------------------------------------
|
| 516 |
-
#
|
| 517 |
# ---------------------------------------------------------------------------
|
| 518 |
|
| 519 |
def _clean_mask(mask, sensitivity=0.5, border_margin=12):
|
|
@@ -524,7 +649,8 @@ def _clean_mask(mask, sensitivity=0.5, border_margin=12):
|
|
| 524 |
3. Opening to remove small specks
|
| 525 |
4. Closing to bridge tiny gaps
|
| 526 |
5. Fill holes inside regions
|
| 527 |
-
6. Erode-then-dilate to break thin noise bridges
|
|
|
|
| 528 |
"""
|
| 529 |
mask = mask.copy()
|
| 530 |
h, w = mask.shape[:2]
|
|
@@ -535,38 +661,51 @@ def _clean_mask(mask, sensitivity=0.5, border_margin=12):
|
|
| 535 |
mask[:, :border_margin] = 0
|
| 536 |
mask[:, -border_margin:] = 0
|
| 537 |
|
| 538 |
-
# 2. Median to remove isolated noise pixels
|
| 539 |
mask = cv2.medianBlur(mask, 5)
|
| 540 |
|
| 541 |
-
# 3. Opening (erosion then dilation) removes small specks
|
| 542 |
open_size = max(3, int(5 * (1 - sensitivity * 0.5)))
|
| 543 |
if open_size % 2 == 0:
|
| 544 |
open_size += 1
|
| 545 |
k_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (open_size, open_size))
|
| 546 |
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k_open)
|
| 547 |
|
| 548 |
-
# 4. Closing to bridge small internal gaps
|
| 549 |
close_size = max(3, int(7 * (1 - sensitivity)))
|
| 550 |
if close_size % 2 == 0:
|
| 551 |
close_size += 1
|
| 552 |
k_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (close_size, close_size))
|
| 553 |
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_close)
|
| 554 |
|
| 555 |
-
# 5. Fill holes inside regions
|
| 556 |
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 557 |
filled = np.zeros_like(mask)
|
| 558 |
cv2.drawContours(filled, contours, -1, 255, thickness=cv2.FILLED)
|
| 559 |
|
| 560 |
-
# 6. Erode to break thin noise bridges, then dilate back
|
| 561 |
k_break = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
|
| 562 |
filled = cv2.erode(filled, k_break, iterations=1)
|
| 563 |
filled = cv2.dilate(filled, k_break, iterations=1)
|
| 564 |
|
| 565 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 566 |
|
| 567 |
|
| 568 |
# ---------------------------------------------------------------------------
|
| 569 |
-
#
|
| 570 |
# ---------------------------------------------------------------------------
|
| 571 |
|
| 572 |
def _severity_from_region(region, total_pixels):
|
|
@@ -660,7 +799,7 @@ def visualize_changes(img1, img2, change_mask, regions=None, total_pixels=None):
|
|
| 660 |
|
| 661 |
|
| 662 |
# ---------------------------------------------------------------------------
|
| 663 |
-
#
|
| 664 |
# ---------------------------------------------------------------------------
|
| 665 |
|
| 666 |
def extract_advanced_features(region):
|
|
@@ -957,7 +1096,7 @@ def classify_with_ensemble(image_region, bbox):
|
|
| 957 |
|
| 958 |
|
| 959 |
# ---------------------------------------------------------------------------
|
| 960 |
-
#
|
| 961 |
# ---------------------------------------------------------------------------
|
| 962 |
|
| 963 |
_VEGETATION_TYPES = {"Vegetation Change"}
|
|
@@ -1094,7 +1233,7 @@ def classify_vegetation_subtype(before_img, after_img, bbox):
|
|
| 1094 |
|
| 1095 |
|
| 1096 |
# ---------------------------------------------------------------------------
|
| 1097 |
-
#
|
| 1098 |
# ---------------------------------------------------------------------------
|
| 1099 |
|
| 1100 |
_STRUCTURAL_TYPES = {"New Construction/Building", "Demolition/Clearing",
|
|
@@ -1286,7 +1425,7 @@ def _classify_road_subtype(struct_b, struct_a, edge_b, edge_a,
|
|
| 1286 |
|
| 1287 |
|
| 1288 |
# ---------------------------------------------------------------------------
|
| 1289 |
-
#
|
| 1290 |
# ---------------------------------------------------------------------------
|
| 1291 |
|
| 1292 |
_BUILDING_TYPES = {"New Construction/Building", "Demolition/Clearing"}
|
|
@@ -1510,7 +1649,7 @@ def analyze_building_3d(before_img, after_img, region, features):
|
|
| 1510 |
|
| 1511 |
|
| 1512 |
# ---------------------------------------------------------------------------
|
| 1513 |
-
#
|
| 1514 |
# ---------------------------------------------------------------------------
|
| 1515 |
|
| 1516 |
def _tight_bbox(labels, label_id, stats_row):
|
|
@@ -1575,8 +1714,8 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
|
|
| 1575 |
before_img=None, registration_ok=True):
|
| 1576 |
"""
|
| 1577 |
Find connected change regions with strict quality filters:
|
| 1578 |
-
-
|
| 1579 |
-
- Fill-ratio filter
|
| 1580 |
- Tighter bounding boxes computed from actual pixel coordinates
|
| 1581 |
- NMS to remove overlapping/duplicate boxes
|
| 1582 |
- Max 60 regions cap to avoid flooding the UI
|
|
@@ -1592,7 +1731,7 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
|
|
| 1592 |
# - keeps sensitivity on smaller images
|
| 1593 |
# - suppresses speckle noise on larger images
|
| 1594 |
if min_area is None:
|
| 1595 |
-
min_area = int(max(
|
| 1596 |
|
| 1597 |
for i in range(1, num_labels):
|
| 1598 |
raw_area = stats[i, cv2.CC_STAT_AREA]
|
|
@@ -1602,7 +1741,7 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
|
|
| 1602 |
x, y, w, h, fill_ratio = _tight_bbox(labels, i, stats[i])
|
| 1603 |
|
| 1604 |
# Reject very sparse regions (bbox is mostly empty)
|
| 1605 |
-
if fill_ratio < 0.
|
| 1606 |
continue
|
| 1607 |
|
| 1608 |
# Keep large real changes; only suppress near-full-frame artifacts.
|
|
@@ -1685,7 +1824,7 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
|
|
| 1685 |
|
| 1686 |
|
| 1687 |
# ---------------------------------------------------------------------------
|
| 1688 |
-
#
|
| 1689 |
# ---------------------------------------------------------------------------
|
| 1690 |
|
| 1691 |
def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
|
|
|
|
| 1 |
"""
|
| 2 |
+
Satellite Change Detection Engine v3
|
| 3 |
+
High-accuracy detection with multi-channel analysis, SSIM, CVA, texture features,
|
| 4 |
+
adaptive thresholding, vegetation/shadow suppression, SNR-weighted fusion,
|
| 5 |
+
and improved object classification.
|
| 6 |
"""
|
| 7 |
import numpy as np
|
| 8 |
import cv2
|
|
|
|
| 17 |
# ---------------------------------------------------------------------------
|
| 18 |
|
| 19 |
def preprocess_image(image):
|
| 20 |
+
"""Preprocess image: convert to RGB, limit size, bilateral denoise."""
|
| 21 |
img_array = np.array(image)
|
| 22 |
if img_array.ndim == 2:
|
| 23 |
img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
|
|
|
|
| 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 |
+
# Bilateral filter: reduces sensor noise while preserving edges
|
| 35 |
+
img_array = cv2.bilateralFilter(img_array, 9, 75, 75)
|
| 36 |
return img_array
|
| 37 |
|
| 38 |
|
|
|
|
| 151 |
|
| 152 |
|
| 153 |
# ---------------------------------------------------------------------------
|
| 154 |
+
# 4. Vegetation suppression
|
| 155 |
# ---------------------------------------------------------------------------
|
| 156 |
|
| 157 |
+
def compute_vegetation_mask(img):
|
| 158 |
+
"""
|
| 159 |
+
Identify vegetation pixels using pseudo-NDVI and HSV hue/saturation.
|
| 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.6 + hsv_veg * 0.4, 0, 1)
|
| 174 |
+
veg = cv2.GaussianBlur(veg, (11, 11), 0)
|
| 175 |
+
return veg
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def compute_combined_vegetation_suppression(img1, img2):
|
| 179 |
+
"""
|
| 180 |
+
Build a suppression map from both images: if EITHER image shows vegetation
|
| 181 |
+
in a region, dampen it. Returns a float map in [0, 1] where
|
| 182 |
+
1.0 = no suppression, ~0.3 = heavy suppression (vegetation area).
|
| 183 |
+
"""
|
| 184 |
+
veg1 = compute_vegetation_mask(img1)
|
| 185 |
+
veg2 = compute_vegetation_mask(img2)
|
| 186 |
+
combined_veg = np.maximum(veg1, veg2)
|
| 187 |
+
suppression = 1.0 - combined_veg * 0.7
|
| 188 |
+
return suppression.astype(np.float32)
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
# ---------------------------------------------------------------------------
|
| 192 |
+
# 5. Shadow / illumination-only change suppression
|
| 193 |
+
# ---------------------------------------------------------------------------
|
| 194 |
+
|
| 195 |
+
def compute_shadow_suppression(img1, img2):
|
| 196 |
+
"""
|
| 197 |
+
Detect pixels where only brightness (L) changed but chrominance (A, B)
|
| 198 |
+
stayed similar. These are shadow/illumination shifts, not real changes.
|
| 199 |
+
Returns a float map in [0, 1]: 1.0 = real change, ~0.2 = illumination-only.
|
| 200 |
+
"""
|
| 201 |
+
lab1 = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 202 |
+
lab2 = cv2.cvtColor(img2, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 203 |
+
|
| 204 |
+
delta_l = np.abs(lab1[:, :, 0] - lab2[:, :, 0])
|
| 205 |
+
delta_a = np.abs(lab1[:, :, 1] - lab2[:, :, 1])
|
| 206 |
+
delta_b = np.abs(lab1[:, :, 2] - lab2[:, :, 2])
|
| 207 |
+
|
| 208 |
+
chroma_change = delta_a + delta_b
|
| 209 |
+
brightness_only = (delta_l > 18) & (chroma_change < 12)
|
| 210 |
+
shadow_map = brightness_only.astype(np.float32)
|
| 211 |
+
shadow_map = cv2.GaussianBlur(shadow_map, (9, 9), 0)
|
| 212 |
+
|
| 213 |
+
suppression = 1.0 - shadow_map * 0.8
|
| 214 |
+
return suppression.astype(np.float32)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
# ---------------------------------------------------------------------------
|
| 218 |
+
# 6. Change Vector Analysis (CVA)
|
| 219 |
+
# ---------------------------------------------------------------------------
|
| 220 |
+
|
| 221 |
+
def compute_cva(img1, img2):
|
| 222 |
+
"""
|
| 223 |
+
Change Vector Analysis in LAB space.
|
| 224 |
+
Returns a normalized change magnitude map with illumination-only
|
| 225 |
+
changes suppressed via direction filtering.
|
| 226 |
+
"""
|
| 227 |
+
lab1 = cv2.cvtColor(img1, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 228 |
+
lab2 = cv2.cvtColor(img2, cv2.COLOR_RGB2LAB).astype(np.float32)
|
| 229 |
+
|
| 230 |
+
dl = (lab2[:, :, 0] - lab1[:, :, 0]) / 100.0
|
| 231 |
+
da = (lab2[:, :, 1] - lab1[:, :, 1]) / 128.0
|
| 232 |
+
db = (lab2[:, :, 2] - lab1[:, :, 2]) / 128.0
|
| 233 |
+
|
| 234 |
+
magnitude = np.sqrt(dl ** 2 + da ** 2 + db ** 2)
|
| 235 |
|
| 236 |
+
chroma_mag = np.sqrt(da ** 2 + db ** 2)
|
| 237 |
+
total_mag = magnitude + 1e-8
|
| 238 |
+
chroma_ratio = chroma_mag / total_mag
|
| 239 |
+
|
| 240 |
+
# Suppress illumination-only changes (low chroma ratio)
|
| 241 |
+
suppression = np.clip(chroma_ratio * 2.5, 0.15, 1.0)
|
| 242 |
+
magnitude = magnitude * suppression
|
| 243 |
+
|
| 244 |
+
p995 = float(np.quantile(magnitude, 0.995))
|
| 245 |
+
if p995 > 1e-8:
|
| 246 |
+
magnitude = np.clip(magnitude / p995, 0, 1)
|
| 247 |
+
|
| 248 |
+
return magnitude.astype(np.float32)
|
| 249 |
+
|
| 250 |
+
|
| 251 |
+
# ---------------------------------------------------------------------------
|
| 252 |
+
# 7. SSIM-based structural change map
|
| 253 |
+
# ---------------------------------------------------------------------------
|
| 254 |
+
|
| 255 |
+
def _ssim_at_scale(gray1, gray2, win_size=11):
|
| 256 |
+
"""Compute SSIM dissimilarity at a single scale."""
|
| 257 |
+
sigma = win_size / 6.0
|
| 258 |
C1 = (0.01 * 255) ** 2
|
| 259 |
C2 = (0.03 * 255) ** 2
|
| 260 |
|
| 261 |
+
mu1 = cv2.GaussianBlur(gray1, (win_size, win_size), sigma)
|
| 262 |
+
mu2 = cv2.GaussianBlur(gray2, (win_size, win_size), sigma)
|
| 263 |
|
| 264 |
mu1_sq = mu1 * mu1
|
| 265 |
mu2_sq = mu2 * mu2
|
| 266 |
mu1_mu2 = mu1 * mu2
|
| 267 |
|
| 268 |
+
sigma1_sq = np.maximum(cv2.GaussianBlur(gray1 * gray1, (win_size, win_size), sigma) - mu1_sq, 0)
|
| 269 |
+
sigma2_sq = np.maximum(cv2.GaussianBlur(gray2 * gray2, (win_size, win_size), sigma) - mu2_sq, 0)
|
| 270 |
+
sigma12 = cv2.GaussianBlur(gray1 * gray2, (win_size, win_size), sigma) - mu1_mu2
|
|
|
|
| 271 |
|
| 272 |
denom = (mu1_sq + mu2_sq + C1) * (sigma1_sq + sigma2_sq + C2)
|
| 273 |
ssim_map = ((2 * mu1_mu2 + C1) * (2 * sigma12 + C2)) / (denom + 1e-12)
|
|
|
|
|
|
|
| 274 |
dssim = np.clip((1.0 - ssim_map) / 2.0, 0, 1)
|
| 275 |
return dssim
|
| 276 |
|
| 277 |
|
| 278 |
+
def compute_ssim_change_map(img1, img2, win_size=11):
|
| 279 |
+
"""
|
| 280 |
+
Multi-scale SSIM dissimilarity: averages full-res and half-res scales
|
| 281 |
+
to capture both fine and coarse structural changes.
|
| 282 |
+
"""
|
| 283 |
+
gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY).astype(np.float64)
|
| 284 |
+
gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY).astype(np.float64)
|
| 285 |
+
|
| 286 |
+
dssim_full = _ssim_at_scale(gray1, gray2, win_size)
|
| 287 |
+
|
| 288 |
+
h, w = gray1.shape
|
| 289 |
+
g1_half = cv2.resize(gray1, (max(1, w // 2), max(1, h // 2)))
|
| 290 |
+
g2_half = cv2.resize(gray2, (max(1, w // 2), max(1, h // 2)))
|
| 291 |
+
half_win = max(3, win_size // 2) | 1
|
| 292 |
+
dssim_half = _ssim_at_scale(g1_half, g2_half, half_win)
|
| 293 |
+
dssim_half_up = cv2.resize(dssim_half, (w, h))
|
| 294 |
+
|
| 295 |
+
dssim = 0.6 * dssim_full + 0.4 * dssim_half_up
|
| 296 |
+
return dssim
|
| 297 |
+
|
| 298 |
+
|
| 299 |
# ---------------------------------------------------------------------------
|
| 300 |
+
# 8. Texture feature extraction (LBP)
|
| 301 |
# ---------------------------------------------------------------------------
|
| 302 |
|
| 303 |
def compute_lbp(gray, radius=1, n_points=8):
|
|
|
|
| 324 |
|
| 325 |
|
| 326 |
# ---------------------------------------------------------------------------
|
| 327 |
+
# 9. Edge-aware change detection
|
| 328 |
# ---------------------------------------------------------------------------
|
| 329 |
|
| 330 |
def compute_edge_change(img1, img2):
|
|
|
|
| 349 |
|
| 350 |
|
| 351 |
# ---------------------------------------------------------------------------
|
| 352 |
+
# 10. Improved detection methods
|
| 353 |
# ---------------------------------------------------------------------------
|
| 354 |
|
| 355 |
def _adaptive_binary_threshold(score_uint8, min_floor=25, sensitivity=0.5):
|
|
|
|
| 465 |
return change_mask
|
| 466 |
|
| 467 |
|
| 468 |
+
def _snr_weight(channel):
|
| 469 |
+
"""
|
| 470 |
+
Signal-to-noise ratio weight: signal = mean of top 5% values,
|
| 471 |
+
noise = std of bottom 50%. Channels with concentrated high responses
|
| 472 |
+
score higher than uniformly noisy ones.
|
| 473 |
+
"""
|
| 474 |
+
flat = channel.ravel()
|
| 475 |
+
p95 = float(np.quantile(flat, 0.95))
|
| 476 |
+
signal = float(np.mean(flat[flat >= p95])) if p95 > 1e-8 else 0.0
|
| 477 |
+
p50 = float(np.quantile(flat, 0.50))
|
| 478 |
+
noise = float(np.std(flat[flat <= p50])) + 1e-8
|
| 479 |
+
return signal / noise
|
| 480 |
+
|
| 481 |
+
|
| 482 |
def _ai_fusion_core(img1, img2, sensitivity=0.5):
|
| 483 |
+
"""
|
| 484 |
+
Single-pass AI fusion with 5 channels, SNR weighting, and
|
| 485 |
+
vegetation + shadow suppression. Returns (mask, debug).
|
| 486 |
+
"""
|
| 487 |
if img1.shape != img2.shape:
|
| 488 |
img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
|
| 489 |
|
|
|
|
| 500 |
else:
|
| 501 |
s1, s2 = lab1, lab2
|
| 502 |
diff = s1 - s2
|
|
|
|
| 503 |
delta_e = np.sqrt((diff[:, :, 0] / 100.0) ** 2 +
|
| 504 |
(diff[:, :, 1] / 128.0) ** 2 +
|
| 505 |
(diff[:, :, 2] / 128.0) ** 2)
|
|
|
|
| 521 |
# ---- Channel 4: Edge change ----
|
| 522 |
edge_change = compute_edge_change(img1, img2)
|
| 523 |
|
| 524 |
+
# ---- Channel 5: Change Vector Analysis ----
|
| 525 |
+
cva_change = compute_cva(img1, img2)
|
| 526 |
+
|
| 527 |
+
# ---- SNR-weighted fusion ----
|
| 528 |
+
channels = [color_change, ssim_change, texture_change, edge_change, cva_change]
|
| 529 |
+
weights = [_snr_weight(ch) for ch in channels]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 530 |
total_w = sum(weights) + 1e-8
|
| 531 |
weights = [w / total_w for w in weights]
|
| 532 |
|
|
|
|
| 533 |
fused = np.zeros_like(color_change, dtype=np.float64)
|
| 534 |
for ch, w in zip(channels, weights):
|
| 535 |
fused += w * ch.astype(np.float64)
|
| 536 |
|
| 537 |
+
# ---- Apply vegetation + shadow suppression before thresholding ----
|
| 538 |
+
veg_suppression = compute_combined_vegetation_suppression(img1, img2)
|
| 539 |
+
shadow_suppression = compute_shadow_suppression(img1, img2)
|
| 540 |
+
fused = fused * veg_suppression.astype(np.float64) * shadow_suppression.astype(np.float64)
|
| 541 |
+
|
| 542 |
+
# Percentile normalization
|
| 543 |
p995 = float(np.quantile(fused, 0.995))
|
| 544 |
if p995 <= 1e-8:
|
| 545 |
p995 = float(fused.max() + 1e-8)
|
| 546 |
fused_norm = np.clip(fused / (p995 + 1e-8), 0.0, 1.0)
|
| 547 |
|
|
|
|
| 548 |
gamma = 0.85
|
| 549 |
fused_norm = np.power(fused_norm, gamma)
|
| 550 |
|
|
|
|
|
|
|
| 551 |
fused_smooth = cv2.GaussianBlur(fused_norm.astype(np.float32), (7, 7), 0)
|
| 552 |
|
|
|
|
| 553 |
sens = float(np.clip(sensitivity, 0.0, 1.0))
|
| 554 |
+
q = 0.945 - (sens - 0.5) * 0.04
|
| 555 |
+
q = float(np.clip(q, 0.88, 0.97))
|
| 556 |
|
| 557 |
thr_score = float(np.quantile(fused_smooth, q))
|
| 558 |
change_mask = (fused_smooth >= thr_score).astype(np.uint8) * 255
|
| 559 |
|
| 560 |
change_mask = _clean_mask(change_mask, sensitivity=sens)
|
| 561 |
|
|
|
|
| 562 |
change_mask = cv2.bilateralFilter(change_mask, 9, 75, 75)
|
| 563 |
_, change_mask = cv2.threshold(change_mask, 127, 255, cv2.THRESH_BINARY)
|
| 564 |
|
|
|
|
| 571 |
"fused_p99": float(np.quantile(fused_smooth, 0.99)),
|
| 572 |
"fused_mean": float(np.mean(fused_smooth)),
|
| 573 |
"sensitivity": float(sensitivity),
|
| 574 |
+
"channel_weights": {
|
| 575 |
+
"color": round(weights[0], 4),
|
| 576 |
+
"ssim": round(weights[1], 4),
|
| 577 |
+
"texture": round(weights[2], 4),
|
| 578 |
+
"edge": round(weights[3], 4),
|
| 579 |
+
"cva": round(weights[4], 4),
|
| 580 |
+
},
|
| 581 |
}
|
| 582 |
return change_mask, debug
|
| 583 |
|
| 584 |
|
| 585 |
def ai_deep_learning_method(img1, img2, sensitivity=0.5):
|
| 586 |
"""
|
| 587 |
+
Single-pass AI fusion with CVA, SNR weighting, and vegetation/shadow
|
| 588 |
+
suppression. The suppression maps make the reverse pass unnecessary,
|
| 589 |
+
halving computation and eliminating OR-induced false positives.
|
|
|
|
| 590 |
"""
|
| 591 |
+
change_mask, core_debug = _ai_fusion_core(img1, img2, sensitivity=sensitivity)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 592 |
|
| 593 |
debug = {
|
| 594 |
"method": "AI-Based Deep Learning",
|
| 595 |
+
"threshold_used": core_debug.get("threshold_used"),
|
|
|
|
|
|
|
|
|
|
| 596 |
"sensitivity": float(sensitivity),
|
| 597 |
+
"core": core_debug,
|
| 598 |
}
|
| 599 |
+
return change_mask, debug
|
| 600 |
|
| 601 |
|
| 602 |
def hybrid_method(img1, img2, sensitivity=0.5):
|
|
|
|
| 638 |
|
| 639 |
|
| 640 |
# ---------------------------------------------------------------------------
|
| 641 |
+
# 11. Robust post-processing
|
| 642 |
# ---------------------------------------------------------------------------
|
| 643 |
|
| 644 |
def _clean_mask(mask, sensitivity=0.5, border_margin=12):
|
|
|
|
| 649 |
3. Opening to remove small specks
|
| 650 |
4. Closing to bridge tiny gaps
|
| 651 |
5. Fill holes inside regions
|
| 652 |
+
6. Erode-then-dilate to break thin noise bridges
|
| 653 |
+
7. Connected-component area + circularity filtering
|
| 654 |
"""
|
| 655 |
mask = mask.copy()
|
| 656 |
h, w = mask.shape[:2]
|
|
|
|
| 661 |
mask[:, :border_margin] = 0
|
| 662 |
mask[:, -border_margin:] = 0
|
| 663 |
|
|
|
|
| 664 |
mask = cv2.medianBlur(mask, 5)
|
| 665 |
|
|
|
|
| 666 |
open_size = max(3, int(5 * (1 - sensitivity * 0.5)))
|
| 667 |
if open_size % 2 == 0:
|
| 668 |
open_size += 1
|
| 669 |
k_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (open_size, open_size))
|
| 670 |
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k_open)
|
| 671 |
|
|
|
|
| 672 |
close_size = max(3, int(7 * (1 - sensitivity)))
|
| 673 |
if close_size % 2 == 0:
|
| 674 |
close_size += 1
|
| 675 |
k_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (close_size, close_size))
|
| 676 |
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k_close)
|
| 677 |
|
|
|
|
| 678 |
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
| 679 |
filled = np.zeros_like(mask)
|
| 680 |
cv2.drawContours(filled, contours, -1, 255, thickness=cv2.FILLED)
|
| 681 |
|
|
|
|
| 682 |
k_break = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
|
| 683 |
filled = cv2.erode(filled, k_break, iterations=1)
|
| 684 |
filled = cv2.dilate(filled, k_break, iterations=1)
|
| 685 |
|
| 686 |
+
# 7. Component-level filtering: remove tiny survivors and elongated noise
|
| 687 |
+
min_component_px = max(80, int(h * w * 0.00004))
|
| 688 |
+
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(filled, connectivity=8)
|
| 689 |
+
clean = np.zeros_like(filled)
|
| 690 |
+
for i in range(1, num_labels):
|
| 691 |
+
area = stats[i, cv2.CC_STAT_AREA]
|
| 692 |
+
if area < min_component_px:
|
| 693 |
+
continue
|
| 694 |
+
cw = stats[i, cv2.CC_STAT_WIDTH]
|
| 695 |
+
ch = stats[i, cv2.CC_STAT_HEIGHT]
|
| 696 |
+
bbox_area = max(cw * ch, 1)
|
| 697 |
+
perimeter_approx = 2 * (cw + ch)
|
| 698 |
+
# Circularity: thin elongated noise has very high perimeter^2/area
|
| 699 |
+
circularity = (perimeter_approx ** 2) / (bbox_area + 1e-8)
|
| 700 |
+
if circularity > 80 and area < min_component_px * 3:
|
| 701 |
+
continue
|
| 702 |
+
clean[labels == i] = 255
|
| 703 |
+
|
| 704 |
+
return clean
|
| 705 |
|
| 706 |
|
| 707 |
# ---------------------------------------------------------------------------
|
| 708 |
+
# 12. Severity classification and improved visualization
|
| 709 |
# ---------------------------------------------------------------------------
|
| 710 |
|
| 711 |
def _severity_from_region(region, total_pixels):
|
|
|
|
| 799 |
|
| 800 |
|
| 801 |
# ---------------------------------------------------------------------------
|
| 802 |
+
# 13. Improved object classification
|
| 803 |
# ---------------------------------------------------------------------------
|
| 804 |
|
| 805 |
def extract_advanced_features(region):
|
|
|
|
| 1096 |
|
| 1097 |
|
| 1098 |
# ---------------------------------------------------------------------------
|
| 1099 |
+
# 14. Vegetation sub-classification
|
| 1100 |
# ---------------------------------------------------------------------------
|
| 1101 |
|
| 1102 |
_VEGETATION_TYPES = {"Vegetation Change"}
|
|
|
|
| 1233 |
|
| 1234 |
|
| 1235 |
# ---------------------------------------------------------------------------
|
| 1236 |
+
# 15. Structural change sub-classification
|
| 1237 |
# ---------------------------------------------------------------------------
|
| 1238 |
|
| 1239 |
_STRUCTURAL_TYPES = {"New Construction/Building", "Demolition/Clearing",
|
|
|
|
| 1425 |
|
| 1426 |
|
| 1427 |
# ---------------------------------------------------------------------------
|
| 1428 |
+
# 16. 3D Building Analysis — height estimation + construction stage
|
| 1429 |
# ---------------------------------------------------------------------------
|
| 1430 |
|
| 1431 |
_BUILDING_TYPES = {"New Construction/Building", "Demolition/Clearing"}
|
|
|
|
| 1649 |
|
| 1650 |
|
| 1651 |
# ---------------------------------------------------------------------------
|
| 1652 |
+
# 17. Region analysis
|
| 1653 |
# ---------------------------------------------------------------------------
|
| 1654 |
|
| 1655 |
def _tight_bbox(labels, label_id, stats_row):
|
|
|
|
| 1714 |
before_img=None, registration_ok=True):
|
| 1715 |
"""
|
| 1716 |
Find connected change regions with strict quality filters:
|
| 1717 |
+
- Adaptive min_area scaled to image size
|
| 1718 |
+
- Fill-ratio filter (>= 0.12) rejects sparse noise boxes
|
| 1719 |
- Tighter bounding boxes computed from actual pixel coordinates
|
| 1720 |
- NMS to remove overlapping/duplicate boxes
|
| 1721 |
- Max 60 regions cap to avoid flooding the UI
|
|
|
|
| 1731 |
# - keeps sensitivity on smaller images
|
| 1732 |
# - suppresses speckle noise on larger images
|
| 1733 |
if min_area is None:
|
| 1734 |
+
min_area = int(max(350, min(1400, img_area * 0.00012)))
|
| 1735 |
|
| 1736 |
for i in range(1, num_labels):
|
| 1737 |
raw_area = stats[i, cv2.CC_STAT_AREA]
|
|
|
|
| 1741 |
x, y, w, h, fill_ratio = _tight_bbox(labels, i, stats[i])
|
| 1742 |
|
| 1743 |
# Reject very sparse regions (bbox is mostly empty)
|
| 1744 |
+
if fill_ratio < 0.12:
|
| 1745 |
continue
|
| 1746 |
|
| 1747 |
# Keep large real changes; only suppress near-full-frame artifacts.
|
|
|
|
| 1824 |
|
| 1825 |
|
| 1826 |
# ---------------------------------------------------------------------------
|
| 1827 |
+
# 18. Main pipeline
|
| 1828 |
# ---------------------------------------------------------------------------
|
| 1829 |
|
| 1830 |
def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
|