Spaces:
Running
Running
Commit ·
53d0258
1
Parent(s): c558d36
Fix water misclassification and reduce detection hallucinations.
Browse filesUse 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>
- Dockerfile +1 -1
- app/detection_engine.py +122 -34
- 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=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
|
| 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.
|
| 810 |
|
| 811 |
if is_model_available():
|
| 812 |
try:
|
| 813 |
-
model_mask,
|
| 814 |
-
|
| 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,
|
| 820 |
img1, img2, sensitivity=sensitivity, registration_ok=registration_ok)
|
| 821 |
|
| 822 |
-
if model_ok and
|
| 823 |
-
|
|
|
|
|
|
|
|
|
|
| 824 |
combined = _clean_mask(combined, sensitivity=sensitivity)
|
| 825 |
debug = {
|
| 826 |
-
"method": "AI-Based Deep Learning (AdaptFormer +
|
| 827 |
"model": "adaptformer-levir-cd",
|
| 828 |
-
"fusion": "
|
| 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(
|
| 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
|
| 1405 |
-
water
|
| 1406 |
-
|
| 1407 |
-
|
| 1408 |
-
|
| 1409 |
-
|
| 1410 |
-
|
| 1411 |
-
|
| 1412 |
-
|
| 1413 |
-
|
| 1414 |
-
|
| 1415 |
-
|
| 1416 |
-
|
| 1417 |
-
|
|
|
|
|
|
|
|
|
|
| 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 =
|
| 1698 |
-
conf = scores[best]
|
| 1699 |
|
| 1700 |
-
if conf < 0.
|
| 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(
|
| 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.
|
| 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 |
-
|
| 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.
|
| 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.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 = "
|
| 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_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,
|