Spaces:
Sleeping
Sleeping
Commit ·
c558d36
1
Parent(s): 16caeeb
Restore union-based detection fusion for higher recall.
Browse filesGated 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>
- Dockerfile +1 -1
- app/detection_engine.py +71 -40
- app/main.py +2 -2
- 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=
|
| 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 |
-
|
|
|
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 801 |
from .model_inference import is_model_available, predict_change_mask
|
| 802 |
|
| 803 |
-
|
| 804 |
model_ok = False
|
| 805 |
-
|
| 806 |
|
| 807 |
if is_model_available():
|
| 808 |
try:
|
| 809 |
-
|
| 810 |
-
|
|
|
|
| 811 |
except Exception as e:
|
| 812 |
_log.warning("AdaptFormer inference failed: %s", e)
|
| 813 |
|
| 814 |
-
|
| 815 |
-
img1, img2, registration_ok=registration_ok)
|
| 816 |
|
| 817 |
-
if model_ok and
|
| 818 |
-
combined
|
| 819 |
-
|
| 820 |
debug = {
|
| 821 |
-
"method": "AI-Based Deep Learning (AdaptFormer +
|
| 822 |
"model": "adaptformer-levir-cd",
|
| 823 |
-
"
|
|
|
|
| 824 |
"sensitivity": float(sensitivity),
|
| 825 |
-
|
|
|
|
|
|
|
| 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 =
|
| 856 |
sens = float(np.clip(sensitivity, 0.0, 1.0))
|
| 857 |
-
hybrid_thr = int(np.clip(base_thr + int((0.5 - sens) * 36),
|
| 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:
|
| 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 |
-
|
|
|
|
| 912 |
dl_method = "none"
|
|
|
|
| 913 |
|
| 914 |
if is_model_available():
|
| 915 |
try:
|
| 916 |
-
|
| 917 |
dl_method = "adaptformer"
|
| 918 |
except Exception:
|
| 919 |
pass
|
| 920 |
|
| 921 |
if dl_method == "none":
|
| 922 |
try:
|
| 923 |
-
from .cd_models.change_model import
|
| 924 |
-
if
|
| 925 |
-
|
| 926 |
dl_method = "siamese_unet"
|
| 927 |
except Exception:
|
| 928 |
pass
|
| 929 |
|
| 930 |
-
|
| 931 |
-
img1, img2, registration_ok=registration_ok)
|
|
|
|
|
|
|
| 932 |
|
| 933 |
-
if dl_method != "none"
|
| 934 |
-
|
| 935 |
-
|
| 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 |
-
|
| 945 |
-
|
| 946 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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.
|
| 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.
|
| 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 = "
|
| 102 |
available = True
|
| 103 |
elif _LOAD_FAILED:
|
| 104 |
mode = "classical_fallback"
|
| 105 |
available = False
|
| 106 |
else:
|
| 107 |
available = is_model_available()
|
| 108 |
-
mode = "
|
| 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,
|