coderuday21 Cursor commited on
Commit
c558d36
·
1 Parent(s): 16caeeb

Restore union-based detection fusion for higher recall.

Browse files

Gated fusion was too strict once AdaptFormer loaded, yielding only 1-2 regions.
Revert to AdaptFormer+classical union, looser thresholds, and prior cleanup.

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

Files changed (4) hide show
  1. Dockerfile +1 -1
  2. app/detection_engine.py +71 -40
  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=24
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=25
23
  ENV APP_BUILD=${APP_BUILD}
24
  RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
25
 
app/detection_engine.py CHANGED
@@ -774,7 +774,8 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5, registration_ok=True):
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)
@@ -797,39 +798,45 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5, registration_ok=True):
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
  }
@@ -852,9 +859,9 @@ def hybrid_method(img1, img2, sensitivity=0.5, registration_ok=True):
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 = {
@@ -901,49 +908,73 @@ def _build_confidence_map_from_channels(img1, img2, dl_score=None):
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 = (
@@ -999,7 +1030,7 @@ def _clean_mask(mask, sensitivity=0.5, border_margin=12):
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):
 
774
  img1, img2, registration_ok=registration_ok)
775
 
776
  sens = float(np.clip(sensitivity, 0.0, 1.0))
777
+ # Looser percentile than gated fusion keeps recall for multi-region detection
778
+ q = float(np.clip(0.93 - (sens - 0.5) * 0.06, 0.85, 0.96))
779
  thr_score = float(np.quantile(classical_score, q))
780
  change_mask = (classical_score >= thr_score).astype(np.uint8) * 255
781
  change_mask = _clean_mask(change_mask, sensitivity=sens)
 
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)),
832
+ "rule_changed_px": int(np.sum(rule_mask > 127)),
833
+ "combined_changed_px": int(np.sum(combined > 127)),
834
  }
835
  return combined, debug
836
 
 
 
837
  debug = {
838
  "method": "AI-Based Deep Learning (classical fallback)",
839
+ "threshold_used": core_debug.get("threshold_used"),
840
  "sensitivity": float(sensitivity),
841
  "core": core_debug,
842
  }
 
859
  0.5 * ai_mask.astype(np.float32)
860
  )
861
 
862
+ base_thr = 98
863
  sens = float(np.clip(sensitivity, 0.0, 1.0))
864
+ hybrid_thr = int(np.clip(base_thr + int((0.5 - sens) * 36), 60, 150))
865
  _, final_mask = cv2.threshold(combined.astype(np.uint8), hybrid_thr, 255, cv2.THRESH_BINARY)
866
  final_mask = _clean_mask(final_mask, sensitivity=sensitivity)
867
  debug = {
 
908
  return build_confidence_map(channels, weights)
909
 
910
 
911
+ def _multiscale_classical(img1, img2, sensitivity=0.5, registration_ok=True):
912
+ """Run classical fusion at multiple scales and OR-combine for better recall."""
913
+ from .cd_models.model_utils import multiscale_detect
914
+
915
+ def _single_scale_detect(s1, s2):
916
+ mask, _, _ = _ai_fusion_core(
917
+ s1, s2, sensitivity=sensitivity, registration_ok=registration_ok)
918
+ return mask
919
+
920
+ return multiscale_detect(_single_scale_detect, img1, img2, scales=(1.0, 0.5))
921
+
922
+
923
  def hybrid_ai_method(img1, img2, sensitivity=0.5, registration_ok=True):
924
+ """Hybrid AI: DL mask + multi-scale classical mask with confidence weighting."""
925
  if img1.shape != img2.shape:
926
  img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
927
 
928
  from .model_inference import is_model_available, predict_change_mask
929
 
930
+ dl_mask = np.zeros(img1.shape[:2], dtype=np.uint8)
931
+ dl_score = np.zeros(img1.shape[:2], dtype=np.float32)
932
  dl_method = "none"
933
+ thr = 0.25 + (1.0 - float(np.clip(sensitivity, 0, 1))) * 0.25
934
 
935
  if is_model_available():
936
  try:
937
+ dl_mask, dl_score = predict_change_mask(img1, img2, threshold=thr)
938
  dl_method = "adaptformer"
939
  except Exception:
940
  pass
941
 
942
  if dl_method == "none":
943
  try:
944
+ from .cd_models.change_model import is_siamese_available, predict_siamese
945
+ if is_siamese_available():
946
+ dl_mask, dl_score = predict_siamese(img1, img2, threshold=thr)
947
  dl_method = "siamese_unet"
948
  except Exception:
949
  pass
950
 
951
+ classical_mask = _multiscale_classical(
952
+ img1, img2, sensitivity=sensitivity, registration_ok=registration_ok)
953
+ conf_map = _build_confidence_map_from_channels(
954
+ img1, img2, dl_score=dl_score if dl_method != "none" else None)
955
 
956
+ dl_w = 0.7 if dl_method != "none" else 0.0
957
+ cl_w = 1.0 - dl_w
958
+ fused = dl_w * dl_mask.astype(np.float32) + cl_w * classical_mask.astype(np.float32)
 
 
 
 
 
 
 
959
 
960
+ if conf_map is not None:
961
+ conf_boost = np.clip(conf_map * 1.5, 0, 1)
962
+ fused = fused * (0.6 + 0.4 * conf_boost)
963
+
964
+ fused_thr = max(80, int(128 - (sensitivity - 0.5) * 60))
965
+ _, final_mask = cv2.threshold(fused.astype(np.uint8), fused_thr, 255, cv2.THRESH_BINARY)
966
+ final_mask = _clean_mask(final_mask, sensitivity=sensitivity)
967
+
968
+ debug = {
969
+ "method": f"Hybrid AI ({dl_method} + multi-scale classical)",
970
+ "dl_method": dl_method,
971
+ "threshold_used": fused_thr,
972
+ "sensitivity": float(sensitivity),
973
+ "dl_changed_px": int(np.sum(dl_mask > 127)),
974
+ "classical_changed_px": int(np.sum(classical_mask > 127)),
975
+ "final_changed_px": int(np.sum(final_mask > 127)),
976
+ }
977
+ return final_mask, debug
978
 
979
 
980
  ALIGNMENT_WARNING_MSG = (
 
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):
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.1")
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.1",
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.2")
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.2",
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_gated_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_gated_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_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,