coderuday21 Cursor commited on
Commit
ba4abf7
·
1 Parent(s): 3e1a5d9

Fix detection precision: gated fusion, strict registration, calibrated thresholds, preload model

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