coderuday21 commited on
Commit
debf6fe
·
1 Parent(s): 8beea23

Redesign detection pipeline: CVA, vegetation/shadow suppression, SNR fusion, multi-scale SSIM

Browse files
Files changed (1) hide show
  1. app/detection_engine.py +216 -77
app/detection_engine.py CHANGED
@@ -1,7 +1,8 @@
1
  """
2
- Satellite Change Detection Engine v2
3
- High-accuracy detection with multi-channel analysis, SSIM, texture features,
4
- adaptive thresholding, and improved object classification.
 
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. SSIM-based structural change map
152
  # ---------------------------------------------------------------------------
153
 
154
- def compute_ssim_change_map(img1, img2, win_size=7):
155
- """Compute per-pixel structural dissimilarity (1 - SSIM)."""
156
- gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY).astype(np.float64)
157
- gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY).astype(np.float64)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
158
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
  C1 = (0.01 * 255) ** 2
160
  C2 = (0.03 * 255) ** 2
161
 
162
- mu1 = cv2.GaussianBlur(gray1, (win_size, win_size), 1.5)
163
- mu2 = cv2.GaussianBlur(gray2, (win_size, win_size), 1.5)
164
 
165
  mu1_sq = mu1 * mu1
166
  mu2_sq = mu2 * mu2
167
  mu1_mu2 = mu1 * mu2
168
 
169
- # Clamp to zero: E[X²]-E[X]² can go slightly negative from float rounding
170
- sigma1_sq = np.maximum(cv2.GaussianBlur(gray1 * gray1, (win_size, win_size), 1.5) - mu1_sq, 0)
171
- sigma2_sq = np.maximum(cv2.GaussianBlur(gray2 * gray2, (win_size, win_size), 1.5) - mu2_sq, 0)
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
- # 5. Texture feature extraction (LBP)
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
- # 6. Edge-aware change detection
211
  # ---------------------------------------------------------------------------
212
 
213
  def compute_edge_change(img1, img2):
@@ -232,7 +349,7 @@ def compute_edge_change(img1, img2):
232
 
233
 
234
  # ---------------------------------------------------------------------------
235
- # 7. Improved detection methods
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
- """One-direction AI fusion core. Returns (mask, debug)."""
 
 
 
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
- # ---- Adaptive fusion ----
392
- # Weight channels by their discriminative power (entropy-based)
393
- channels = [color_change, ssim_change, texture_change, edge_change]
394
- weights = []
395
- for ch in channels:
396
- ch_uint8 = (ch * 255).astype(np.uint8)
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
- # Percentile normalization: max-normalization can make the distribution too peaky,
412
- # causing an overly strict threshold on some scenes.
 
 
 
 
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.958 - (sens - 0.5) * 0.04
429
- q = float(np.clip(q, 0.90, 0.98))
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
- Bidirectional AI fusion for robustness:
456
- - run core on (before, after) and (after, before)
457
- - combine with OR then clean
458
- This improves stability for asymmetric scenes / normalization drift.
459
  """
460
- fwd_mask, fwd_debug = _ai_fusion_core(img1, img2, sensitivity=sensitivity)
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": fwd_debug.get("threshold_used"),
469
- "bidirectional": True,
470
- "forward": fwd_debug,
471
- "reverse": rev_debug,
472
  "sensitivity": float(sensitivity),
 
473
  }
474
- return combined, debug
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
- # 8. Robust post-processing
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 between separate changes
 
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
- return filled
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
566
 
567
 
568
  # ---------------------------------------------------------------------------
569
- # 9. Severity classification and improved visualization
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
- # 10. Improved object classification
664
  # ---------------------------------------------------------------------------
665
 
666
  def extract_advanced_features(region):
@@ -957,7 +1096,7 @@ def classify_with_ensemble(image_region, bbox):
957
 
958
 
959
  # ---------------------------------------------------------------------------
960
- # 11. Vegetation sub-classification
961
  # ---------------------------------------------------------------------------
962
 
963
  _VEGETATION_TYPES = {"Vegetation Change"}
@@ -1094,7 +1233,7 @@ def classify_vegetation_subtype(before_img, after_img, bbox):
1094
 
1095
 
1096
  # ---------------------------------------------------------------------------
1097
- # 12. Structural change sub-classification
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
- # 13. 3D Building Analysis — height estimation + construction stage
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
- # 14. Region analysis
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
- - Higher min_area (400) to reject noise
1579
- - Fill-ratio filter: reject boxes that are mostly empty
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(250, min(1200, img_area * 0.00008)))
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.10:
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
- # 15. Main pipeline
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",