coderuday21 Cursor commited on
Commit
53d0258
·
1 Parent(s): c558d36

Fix water misclassification and reduce detection hallucinations.

Browse files

Use confidence-pruned union fusion, structural evidence to prefer building
over water, and stricter region filters for low-confidence speckle.

Co-authored-by: Cursor <cursoragent@cursor.com>

Files changed (4) hide show
  1. Dockerfile +1 -1
  2. app/detection_engine.py +122 -34
  3. app/main.py +2 -2
  4. app/model_inference.py +2 -2
Dockerfile CHANGED
@@ -19,7 +19,7 @@ WORKDIR /app
19
 
20
  # Build-time info + cache-bust:
21
  # Changing APP_BUILD forces Docker to re-run subsequent layers (including pip install).
22
- ARG APP_BUILD=25
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=26
23
  ENV APP_BUILD=${APP_BUILD}
24
  RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
25
 
app/detection_engine.py CHANGED
@@ -797,35 +797,120 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5, registration_ok=True):
797
  return change_mask, classical_score, debug
798
 
799
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
800
  def ai_deep_learning_method(img1, img2, sensitivity=0.5, registration_ok=True):
801
  """
802
- Dual-engine approach: AdaptFormer for structure + classical fusion for
803
- vegetation/texture. Union (not gated AND) maximizes recall.
804
  """
805
  from .model_inference import is_model_available, predict_change_mask
806
 
807
  model_mask = None
 
808
  model_ok = False
809
- threshold = 0.25 + (1.0 - float(np.clip(sensitivity, 0, 1))) * 0.25
810
 
811
  if is_model_available():
812
  try:
813
- model_mask, _ = predict_change_mask(img1, img2, threshold=threshold)
814
- model_mask = _clean_mask(model_mask, sensitivity=sensitivity)
815
- model_ok = model_mask is not None
816
  except Exception as e:
817
  _log.warning("AdaptFormer inference failed: %s", e)
818
 
819
- rule_mask, _, core_debug = _ai_fusion_core(
820
  img1, img2, sensitivity=sensitivity, registration_ok=registration_ok)
821
 
822
- if model_ok and model_mask is not None:
823
- combined = np.maximum(model_mask, rule_mask)
 
 
 
824
  combined = _clean_mask(combined, sensitivity=sensitivity)
825
  debug = {
826
- "method": "AI-Based Deep Learning (AdaptFormer + rule-based union)",
827
  "model": "adaptformer-levir-cd",
828
- "fusion": "union",
829
  "threshold_used": int(threshold * 255),
830
  "sensitivity": float(sensitivity),
831
  "model_changed_px": int(np.sum(model_mask > 127)),
@@ -1030,7 +1115,7 @@ def _clean_mask(mask, sensitivity=0.5, border_margin=12):
1030
  filled = cv2.dilate(filled, k_break, iterations=1)
1031
 
1032
  # 7. Component-level filtering: remove tiny survivors and elongated noise
1033
- min_component_px = max(50, int(h * w * 0.00003))
1034
  num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(filled, connectivity=8)
1035
  clean = np.zeros_like(filled)
1036
  for i in range(1, num_labels):
@@ -1401,20 +1486,23 @@ def classify_object_type(image_region, bbox, before_region=None):
1401
 
1402
  # ---- Water Body Change ----
1403
  water = 0.0
1404
- if feat_a["blue_ratio"] > 0.36:
1405
- water += 0.22
1406
- if feat_a["texture_std"] < 28:
1407
- water += 0.18
1408
- if feat_a["edge_density"] < 35:
1409
- water += 0.14
1410
- if 90 <= feat_a["hue"] <= 135:
1411
- water += 0.18
1412
- if feat_a["lbp_variance"] < 0.05:
1413
- water += 0.14
1414
- if feat_a["glcm_contrast"] < 500:
1415
- water += 0.10
1416
- if area > 800:
1417
- water += 0.04
 
 
 
1418
  scores["Water Body Change"] = water
1419
 
1420
  # ---- Vegetation Change ----
@@ -1694,10 +1782,9 @@ def classify_object_type(image_region, bbox, before_region=None):
1694
  soil += 0.10
1695
  scores["Bare Land/Soil Change"] = soil
1696
 
1697
- best = max(scores, key=scores.get)
1698
- conf = scores[best]
1699
 
1700
- if conf < 0.22:
1701
  return "Unclassified", conf
1702
  return best, min(conf, 1.0)
1703
 
@@ -2387,7 +2474,7 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
2387
  # - keeps sensitivity on smaller images
2388
  # - suppresses speckle noise on larger images
2389
  if min_area is None:
2390
- min_area = int(max(200, min(1000, img_area * 0.00008)))
2391
 
2392
  for i in range(1, num_labels):
2393
  raw_area = stats[i, cv2.CC_STAT_AREA]
@@ -2397,7 +2484,7 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
2397
  x, y, w, h, fill_ratio = _tight_bbox(labels, i, stats[i])
2398
 
2399
  # Reject very sparse regions (bbox is mostly empty)
2400
- if fill_ratio < 0.12:
2401
  continue
2402
 
2403
  # Keep large real changes; only suppress near-full-frame artifacts.
@@ -2416,14 +2503,15 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
2416
  image, (x, y, w, h), before_region=before_img)
2417
 
2418
  if object_type is None:
2419
- # Do not silently drop large coherent regions; keep them as generic
2420
- # ground-change candidates so key changes are still surfaced.
2421
- if raw_area >= max(min_area * 2, 800) and fill_ratio >= 0.18:
2422
  object_type = "Unclassified Ground Change"
2423
  confidence = max(0.2, min(0.5, fill_ratio))
2424
  else:
2425
  continue
2426
 
 
 
 
2427
  region_id += 1
2428
  region = {
2429
  "id": region_id,
 
797
  return change_mask, classical_score, debug
798
 
799
 
800
+ def _smart_union_fusion(model_mask, rule_mask, dl_score, classical_score, sensitivity=0.5):
801
+ """
802
+ Union with confidence pruning: keep pixels where at least one engine is
803
+ confident, or both agree. Drops weak single-engine speckle (hallucinations).
804
+ """
805
+ sens = float(np.clip(sensitivity, 0.0, 1.0))
806
+ model_on = model_mask > 127
807
+ rule_on = rule_mask > 127
808
+ both_agree = model_on & rule_on
809
+
810
+ dl_floor = 0.32 + (1.0 - sens) * 0.10
811
+ cl_q = float(np.clip(0.91 - (sens - 0.5) * 0.03, 0.87, 0.94))
812
+ cl_floor = (
813
+ float(np.quantile(classical_score, cl_q))
814
+ if float(classical_score.max()) > 1e-6 else 0.38
815
+ )
816
+
817
+ dl_ok = dl_score >= dl_floor
818
+ cl_ok = classical_score >= cl_floor
819
+ keep = both_agree | (model_on & dl_ok) | (rule_on & cl_ok)
820
+ return np.where(keep, 255, 0).astype(np.uint8)
821
+
822
+
823
+ def _structural_evidence(diff, feat_a):
824
+ """Score how strongly a region looks like built structure (not water/vegetation)."""
825
+ score = 0.0
826
+ if diff:
827
+ if diff.get("delta_lines", 0) > 2:
828
+ score += 0.28
829
+ if diff.get("delta_corners", 0) > 3:
830
+ score += 0.24
831
+ if diff.get("delta_edge_density", 0) > 8:
832
+ score += 0.20
833
+ if diff.get("hull_ratio_after", 0) > 0.35:
834
+ score += 0.18
835
+ if diff.get("lines_after", 0) > 4:
836
+ score += 0.14
837
+ if diff.get("ssim", 1.0) < 0.65:
838
+ score += 0.12
839
+ if feat_a.get("edge_density", 0) > 35:
840
+ score += 0.18
841
+ if feat_a.get("orientation_entropy", 3.0) < 2.4:
842
+ score += 0.14
843
+ return min(1.0, score)
844
+
845
+
846
+ def _resolve_classification(scores, diff, feat_a):
847
+ """Apply cross-type constraints; fix water vs construction confusion."""
848
+ structural = _structural_evidence(diff, feat_a)
849
+
850
+ water = scores.get("Water Body Change", 0.0)
851
+ bld = scores.get("New Construction/Building", 0.0)
852
+
853
+ water_cues = sum([
854
+ feat_a["blue_ratio"] > 0.38,
855
+ feat_a["edge_density"] < 28,
856
+ feat_a["texture_std"] < 26,
857
+ 95 <= feat_a["hue"] <= 130,
858
+ feat_a["lbp_variance"] < 0.045,
859
+ ])
860
+ if water_cues < 3:
861
+ scores["Water Body Change"] = water * 0.45
862
+ elif water_cues < 4:
863
+ scores["Water Body Change"] = water * 0.75
864
+
865
+ if structural >= 0.30:
866
+ scores["Water Body Change"] *= max(0.1, 1.0 - structural * 1.2)
867
+ scores["New Construction/Building"] = min(1.0, bld + structural * 0.45)
868
+
869
+ best = max(scores, key=scores.get)
870
+ conf = scores[best]
871
+
872
+ if (
873
+ best == "Water Body Change"
874
+ and scores["New Construction/Building"] >= conf * 0.72
875
+ and structural >= 0.22
876
+ ):
877
+ best = "New Construction/Building"
878
+ conf = scores["New Construction/Building"]
879
+
880
+ return best, conf
881
+
882
+
883
  def ai_deep_learning_method(img1, img2, sensitivity=0.5, registration_ok=True):
884
  """
885
+ Dual-engine: AdaptFormer + classical fusion with confidence-pruned union.
 
886
  """
887
  from .model_inference import is_model_available, predict_change_mask
888
 
889
  model_mask = None
890
+ dl_score = None
891
  model_ok = False
892
+ threshold = 0.30 + (1.0 - float(np.clip(sensitivity, 0, 1))) * 0.22
893
 
894
  if is_model_available():
895
  try:
896
+ model_mask, dl_score = predict_change_mask(img1, img2, threshold=threshold)
897
+ model_ok = dl_score is not None
 
898
  except Exception as e:
899
  _log.warning("AdaptFormer inference failed: %s", e)
900
 
901
+ rule_mask, classical_score, core_debug = _ai_fusion_core(
902
  img1, img2, sensitivity=sensitivity, registration_ok=registration_ok)
903
 
904
+ if model_ok and dl_score is not None:
905
+ if model_mask is None:
906
+ model_mask = (dl_score >= threshold).astype(np.uint8) * 255
907
+ combined = _smart_union_fusion(
908
+ model_mask, rule_mask, dl_score, classical_score, sensitivity=sensitivity)
909
  combined = _clean_mask(combined, sensitivity=sensitivity)
910
  debug = {
911
+ "method": "AI-Based Deep Learning (AdaptFormer + confidence union)",
912
  "model": "adaptformer-levir-cd",
913
+ "fusion": "smart_union",
914
  "threshold_used": int(threshold * 255),
915
  "sensitivity": float(sensitivity),
916
  "model_changed_px": int(np.sum(model_mask > 127)),
 
1115
  filled = cv2.dilate(filled, k_break, iterations=1)
1116
 
1117
  # 7. Component-level filtering: remove tiny survivors and elongated noise
1118
+ min_component_px = max(80, int(h * w * 0.000035))
1119
  num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(filled, connectivity=8)
1120
  clean = np.zeros_like(filled)
1121
  for i in range(1, num_labels):
 
1486
 
1487
  # ---- Water Body Change ----
1488
  water = 0.0
1489
+ if diff and _structural_evidence(diff, feat_a) >= 0.25:
1490
+ water = 0.0
1491
+ else:
1492
+ if feat_a["blue_ratio"] > 0.38:
1493
+ water += 0.22
1494
+ if feat_a["texture_std"] < 26:
1495
+ water += 0.18
1496
+ if feat_a["edge_density"] < 28:
1497
+ water += 0.16
1498
+ if 95 <= feat_a["hue"] <= 130:
1499
+ water += 0.18
1500
+ if feat_a["lbp_variance"] < 0.045:
1501
+ water += 0.14
1502
+ if feat_a["glcm_contrast"] < 450:
1503
+ water += 0.08
1504
+ if area > 1200:
1505
+ water += 0.04
1506
  scores["Water Body Change"] = water
1507
 
1508
  # ---- Vegetation Change ----
 
1782
  soil += 0.10
1783
  scores["Bare Land/Soil Change"] = soil
1784
 
1785
+ best, conf = _resolve_classification(scores, diff, feat_a)
 
1786
 
1787
+ if conf < 0.28:
1788
  return "Unclassified", conf
1789
  return best, min(conf, 1.0)
1790
 
 
2474
  # - keeps sensitivity on smaller images
2475
  # - suppresses speckle noise on larger images
2476
  if min_area is None:
2477
+ min_area = int(max(250, min(1000, img_area * 0.00009)))
2478
 
2479
  for i in range(1, num_labels):
2480
  raw_area = stats[i, cv2.CC_STAT_AREA]
 
2484
  x, y, w, h, fill_ratio = _tight_bbox(labels, i, stats[i])
2485
 
2486
  # Reject very sparse regions (bbox is mostly empty)
2487
+ if fill_ratio < 0.15:
2488
  continue
2489
 
2490
  # Keep large real changes; only suppress near-full-frame artifacts.
 
2503
  image, (x, y, w, h), before_region=before_img)
2504
 
2505
  if object_type is None:
2506
+ if raw_area >= max(min_area * 2, 900) and fill_ratio >= 0.20:
 
 
2507
  object_type = "Unclassified Ground Change"
2508
  confidence = max(0.2, min(0.5, fill_ratio))
2509
  else:
2510
  continue
2511
 
2512
+ if confidence < 0.24 and raw_area < min_area * 3:
2513
+ continue
2514
+
2515
  region_id += 1
2516
  region = {
2517
  "id": region_id,
app/main.py CHANGED
@@ -70,7 +70,7 @@ except Exception as e:
70
  import logging
71
  logging.getLogger("uvicorn.error").warning("Startup migration skipped: %s", e)
72
 
73
- app = FastAPI(title="AI Change Detection", version="2.2.2")
74
 
75
 
76
  @app.get("/health")
@@ -82,7 +82,7 @@ def health():
82
  model = get_model_status()
83
  return {
84
  "status": "ok" if model.get("available") else "degraded",
85
- "version": "2.2.2",
86
  "server_time_ist": _isoformat_ist(datetime.now(timezone.utc)),
87
  "adaptFormer": model,
88
  }
 
70
  import logging
71
  logging.getLogger("uvicorn.error").warning("Startup migration skipped: %s", e)
72
 
73
+ app = FastAPI(title="AI Change Detection", version="2.2.3")
74
 
75
 
76
  @app.get("/health")
 
82
  model = get_model_status()
83
  return {
84
  "status": "ok" if model.get("available") else "degraded",
85
+ "version": "2.2.3",
86
  "server_time_ist": _isoformat_ist(datetime.now(timezone.utc)),
87
  "adaptFormer": model,
88
  }
app/model_inference.py CHANGED
@@ -98,14 +98,14 @@ def preload_model():
98
  def get_model_status() -> dict:
99
  """Status for /health — shows whether AI detection or classical fallback is active."""
100
  if _AVAILABLE is True:
101
- mode = "adaptformer_union_fusion"
102
  available = True
103
  elif _LOAD_FAILED:
104
  mode = "classical_fallback"
105
  available = False
106
  else:
107
  available = is_model_available()
108
- mode = "adaptformer_union_fusion" if available else "classical_fallback"
109
 
110
  return {
111
  "modelId": _MODEL_ID,
 
98
  def get_model_status() -> dict:
99
  """Status for /health — shows whether AI detection or classical fallback is active."""
100
  if _AVAILABLE is True:
101
+ mode = "adaptformer_smart_union"
102
  available = True
103
  elif _LOAD_FAILED:
104
  mode = "classical_fallback"
105
  available = False
106
  else:
107
  available = is_model_available()
108
+ mode = "adaptformer_smart_union" if available else "classical_fallback"
109
 
110
  return {
111
  "modelId": _MODEL_ID,