coderuday21 commited on
Commit
aa4d14b
·
1 Parent(s): ce994d8

Add SIFT+FLANN registration, Siamese U-Net, multi-scale detection, ExG vegetation, Hybrid AI mode, confidence maps

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=17
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=19
23
  ENV APP_BUILD=${APP_BUILD}
24
  RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
25
 
app/detection_engine.py CHANGED
@@ -1,9 +1,11 @@
1
  """
2
- Satellite Change Detection Engine v3
3
  High-accuracy detection with multi-channel analysis, SSIM, CVA, texture features,
4
  adaptive thresholding, vegetation/shadow suppression, SNR-weighted fusion,
5
- and improved object classification.
 
6
  """
 
7
  import numpy as np
8
  import cv2
9
  from PIL import Image
@@ -11,28 +13,46 @@ from sklearn.cluster import KMeans
11
  from sklearn.preprocessing import StandardScaler
12
  from collections import Counter
13
 
 
 
14
 
15
  # ---------------------------------------------------------------------------
16
  # 1. Pre-processing
17
  # ---------------------------------------------------------------------------
18
 
19
- def preprocess_image(image):
20
- """Preprocess image: convert to RGB, limit size, bilateral denoise."""
21
- img_array = np.array(image)
22
  if img_array.ndim == 2:
23
  img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
24
  elif img_array.ndim == 3 and img_array.shape[2] == 4:
25
  img_array = cv2.cvtColor(img_array, cv2.COLOR_RGBA2RGB)
26
  elif img_array.ndim != 3 or img_array.shape[2] != 3:
27
  raise ValueError(f"Unsupported image shape: {img_array.shape}")
28
- max_size = 2000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
29
  height, width = img_array.shape[:2]
30
  if max(height, width) > max_size:
31
  scale = max_size / max(height, width)
32
  new_w, new_h = max(1, int(width * scale)), max(1, int(height * scale))
33
  img_array = cv2.resize(img_array, (new_w, new_h), interpolation=cv2.INTER_AREA)
34
- # Bilateral filter: reduces sensor noise while preserving edges
35
- img_array = cv2.bilateralFilter(img_array, 9, 75, 75)
 
 
 
36
  return img_array
37
 
38
 
@@ -40,83 +60,188 @@ def preprocess_image(image):
40
  # 2. Improved image registration (alignment)
41
  # ---------------------------------------------------------------------------
42
 
43
- def register_images(img1, img2, max_features=2000):
44
- """Align img2 to img1 using ORB + ratio-test + RANSAC homography."""
45
- gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY)
46
- gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
 
48
- orb = cv2.ORB_create(nfeatures=max_features, scoreType=cv2.ORB_HARRIS_SCORE)
49
- kp1, des1 = orb.detectAndCompute(gray1, None)
50
- kp2, des2 = orb.detectAndCompute(gray2, None)
51
 
52
- if des1 is None or des2 is None or len(des1) < 10 or len(des2) < 10:
53
- return _register_images_ecc_fallback(img1, img2)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
 
55
- # Use kNN matching with Lowe's ratio test for better matches
56
- bf = cv2.BFMatcher(cv2.NORM_HAMMING)
57
- raw_matches = bf.knnMatch(des1, des2, k=2)
 
 
 
 
 
 
58
 
59
- good_matches = []
60
- for pair in raw_matches:
61
- if len(pair) == 2:
62
- m, n = pair
63
- if m.distance < 0.75 * n.distance:
64
- good_matches.append(m)
65
 
66
- if len(good_matches) < 10:
67
- return _register_images_ecc_fallback(img1, img2)
68
 
69
- src_pts = np.float32([kp1[m.queryIdx].pt for m in good_matches]).reshape(-1, 1, 2)
70
- dst_pts = np.float32([kp2[m.trainIdx].pt for m in good_matches]).reshape(-1, 1, 2)
71
 
72
- homography, mask = cv2.findHomography(dst_pts, src_pts, cv2.RANSAC, 3.0)
73
- if homography is None:
74
- return _register_images_ecc_fallback(img1, img2)
 
 
75
 
76
- inlier_ratio = np.sum(mask) / len(mask) if mask is not None else 0
77
- if inlier_ratio < 0.3:
78
- return _register_images_ecc_fallback(img1, img2)
 
 
79
 
80
- # Reject degenerate homographies (near-singular or extreme distortion)
81
- det = np.linalg.det(homography)
82
- if abs(det) < 0.1 or abs(det) > 10.0:
83
- return _register_images_ecc_fallback(img1, img2)
84
 
85
- h, w = img1.shape[:2]
86
- img2_aligned = cv2.warpPerspective(img2, homography, (w, h), borderMode=cv2.BORDER_REFLECT)
87
- return img1, img2_aligned, True
88
 
 
 
 
 
 
 
89
 
90
- def _register_images_ecc_fallback(img1, img2):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
91
  """
92
- Fallback alignment with ECC affine registration.
93
- More stable than ORB on low-texture agricultural areas.
94
  """
95
  try:
96
  gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY)
97
  gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY)
98
- gray1_f = gray1.astype(np.float32) / 255.0
99
- gray2_f = gray2.astype(np.float32) / 255.0
100
 
 
 
101
  warp = np.eye(2, 3, dtype=np.float32)
102
- criteria = (
103
- cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT,
104
- 200,
105
- 1e-6,
106
- )
107
- cc, warp = cv2.findTransformECC(
108
- gray1_f, gray2_f, warp, cv2.MOTION_AFFINE, criteria
109
- )
110
- h, w = img1.shape[:2]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
111
  aligned = cv2.warpAffine(
112
- img2,
113
- warp,
114
- (w, h),
115
  flags=cv2.INTER_LINEAR + cv2.WARP_INVERSE_MAP,
116
- borderMode=cv2.BORDER_REFLECT,
117
- )
118
- # Treat as successful only if ECC correlation is reasonable.
119
- return img1, aligned, bool(cc >= 0.45)
 
 
 
 
120
  except Exception:
121
  return img1, img2, False
122
 
@@ -154,23 +279,43 @@ def normalize_radiometry(img1, img2):
154
  # 4. Vegetation suppression
155
  # ---------------------------------------------------------------------------
156
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
157
  def compute_vegetation_mask(img):
158
  """
159
- Identify vegetation pixels using pseudo-NDVI and HSV hue/saturation.
 
 
 
160
  Returns a float map in [0, 1] where 1.0 = vegetation, 0.0 = non-vegetation.
161
  """
162
  r = img[:, :, 0].astype(np.float32)
163
  g = img[:, :, 1].astype(np.float32)
164
  ndvi = (g - r) / (g + r + 1e-6)
165
 
 
 
166
  hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)
167
  hue = hsv[:, :, 0].astype(np.float32)
168
  sat = hsv[:, :, 1].astype(np.float32)
169
 
170
  ndvi_veg = (ndvi > 0.08).astype(np.float32)
 
171
  hsv_veg = ((hue >= 35) & (hue <= 85) & (sat > 30)).astype(np.float32)
172
 
173
- veg = np.clip(ndvi_veg * 0.6 + hsv_veg * 0.4, 0, 1)
174
  veg = cv2.GaussianBlur(veg, (11, 11), 0)
175
  return veg
176
 
@@ -554,8 +699,8 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5):
554
  fused_smooth = cv2.GaussianBlur(fused_norm.astype(np.float32), (7, 7), 0)
555
 
556
  sens = float(np.clip(sensitivity, 0.0, 1.0))
557
- q = 0.945 - (sens - 0.5) * 0.04
558
- q = float(np.clip(q, 0.88, 0.97))
559
 
560
  thr_score = float(np.quantile(fused_smooth, q))
561
  change_mask = (fused_smooth >= thr_score).astype(np.uint8) * 255
@@ -587,37 +732,53 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5):
587
 
588
  def ai_deep_learning_method(img1, img2, sensitivity=0.5):
589
  """
590
- Uses the pre-trained AdaptFormer model when available; falls back to the
591
- rule-based multi-channel fusion otherwise.
 
 
592
  """
593
  from .model_inference import is_model_available, predict_change_mask
 
 
 
 
 
594
 
595
  if is_model_available():
596
  threshold = 0.25 + (1.0 - sensitivity) * 0.25
597
  try:
598
- change_mask, score_map = predict_change_mask(
599
  img1, img2, threshold=threshold)
600
- change_mask = _clean_mask(change_mask, sensitivity=sensitivity)
601
- debug = {
602
- "method": "AI-Based Deep Learning (AdaptFormer)",
603
- "model": "adaptformer-levir-cd",
604
- "threshold_used": int(threshold * 255),
605
- "sensitivity": float(sensitivity),
606
- }
607
- return change_mask, debug
608
  except Exception as e:
609
- import logging
610
- logging.getLogger(__name__).warning(
611
- "AdaptFormer inference failed, falling back to rule-based: %s", e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
612
 
613
- change_mask, core_debug = _ai_fusion_core(img1, img2, sensitivity=sensitivity)
614
  debug = {
615
  "method": "AI-Based Deep Learning (rule-based fallback)",
616
  "threshold_used": core_debug.get("threshold_used"),
617
  "sensitivity": float(sensitivity),
618
  "core": core_debug,
619
  }
620
- return change_mask, debug
621
 
622
 
623
  def hybrid_method(img1, img2, sensitivity=0.5):
@@ -658,6 +819,121 @@ def hybrid_method(img1, img2, sensitivity=0.5):
658
  return final_mask, debug
659
 
660
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
661
  # ---------------------------------------------------------------------------
662
  # 11. Robust post-processing
663
  # ---------------------------------------------------------------------------
@@ -682,15 +958,15 @@ def _clean_mask(mask, sensitivity=0.5, border_margin=12):
682
  mask[:, :border_margin] = 0
683
  mask[:, -border_margin:] = 0
684
 
685
- mask = cv2.medianBlur(mask, 5)
686
 
687
- open_size = max(3, int(5 * (1 - sensitivity * 0.5)))
688
  if open_size % 2 == 0:
689
  open_size += 1
690
  k_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (open_size, open_size))
691
  mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k_open)
692
 
693
- close_size = max(3, int(7 * (1 - sensitivity)))
694
  if close_size % 2 == 0:
695
  close_size += 1
696
  k_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (close_size, close_size))
@@ -863,6 +1139,7 @@ def extract_advanced_features(region):
863
 
864
  # Vegetation indices
865
  ndvi = (mean_rgb[1] - mean_rgb[0]) / (mean_rgb[1] + mean_rgb[0] + 1e-6)
 
866
 
867
  # Texture
868
  texture_std = float(np.std(gray))
@@ -888,7 +1165,8 @@ def extract_advanced_features(region):
888
 
889
  return {
890
  "mean_rgb": mean_rgb, "std_rgb": std_rgb, "mean_hsv": mean_hsv, "mean_lab": mean_lab,
891
- "ndvi": ndvi, "texture_std": texture_std, "lbp_variance": lbp_variance,
 
892
  "edge_density": edge_density, "orientation_entropy": orientation_entropy,
893
  "glcm_contrast": glcm_contrast,
894
  "color_homogeneity": float(np.mean(std_rgb)),
@@ -1010,6 +1288,7 @@ def _extract_differential_features(before_crop, after_crop):
1010
  return {
1011
  "before": feat_b, "after": feat_a,
1012
  "delta_ndvi": feat_a["ndvi"] - feat_b["ndvi"],
 
1013
  "delta_green_ratio": feat_a["green_ratio"] - feat_b["green_ratio"],
1014
  "delta_edge_density": feat_a["edge_density"] - feat_b["edge_density"],
1015
  "delta_brightness": feat_a["brightness"] - feat_b["brightness"],
@@ -1092,17 +1371,22 @@ def classify_object_type(image_region, bbox, before_region=None):
1092
  if diff:
1093
  # Differential: detect actual vegetation gain or loss
1094
  if abs(diff["delta_ndvi"]) > 0.08:
1095
- veg += 0.30
1096
- if abs(diff["delta_green_ratio"]) > 0.04:
 
1097
  veg += 0.20
 
 
 
 
1098
  if diff["lab_color_distance"] > 15 and (
1099
  diff["before"]["ndvi"] > 0.05 or diff["after"]["ndvi"] > 0.05):
1100
- veg += 0.15
1101
  if abs(diff["delta_saturation"]) > 15 and (
1102
  diff["before"]["green_ratio"] > 0.34 or diff["after"]["green_ratio"] > 0.34):
1103
- veg += 0.15
1104
  if diff["delta_lines"] < 3 and diff["delta_corners"] < 5:
1105
- veg += 0.08
1106
  if area > 500:
1107
  veg += 0.04
1108
  else:
@@ -1301,6 +1585,46 @@ def classify_object_type(image_region, bbox, before_region=None):
1301
  road += 0.03
1302
  scores["Road/Pavement Change"] = road
1303
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1304
  # ---- Bare Land/Soil Change ----
1305
  soil = 0.0
1306
  if feat_a["red_ratio"] > 0.34 and feat_a["green_ratio"] < 0.36:
@@ -1322,7 +1646,7 @@ def classify_object_type(image_region, bbox, before_region=None):
1322
  best = max(scores, key=scores.get)
1323
  conf = scores[best]
1324
 
1325
- if conf < 0.30:
1326
  return "Unclassified", conf
1327
  return best, min(conf, 1.0)
1328
 
@@ -1518,7 +1842,7 @@ def classify_vegetation_subtype(before_img, after_img, bbox):
1518
  # ---------------------------------------------------------------------------
1519
 
1520
  _STRUCTURAL_TYPES = {"New Construction/Building", "Demolition/Clearing",
1521
- "Road/Pavement Change"}
1522
 
1523
 
1524
  def _region_has_structure(crop):
@@ -2139,6 +2463,10 @@ def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
2139
  "note": "KMeans clustering path does not use binary threshold.",
2140
  "sensitivity": float(detection_sensitivity),
2141
  }
 
 
 
 
2142
  else:
2143
  change_mask, threshold_debug = hybrid_method(
2144
  before_array, after_array, sensitivity=detection_sensitivity
@@ -2152,7 +2480,7 @@ def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
2152
  changed_pixels_ratio = float(np.sum(change_mask > 127)) / float(total_pixels) if total_pixels else 0.0
2153
 
2154
  used_fallback = False
2155
- if method in ("AI-Based Deep Learning", "Hybrid Approach") and changed_pixels_ratio < 0.0025:
2156
  diff_mask, diff_debug = image_difference_method(
2157
  before_array, after_array, sensitivity=detection_sensitivity
2158
  )
 
1
  """
2
+ Satellite Change Detection Engine v4
3
  High-accuracy detection with multi-channel analysis, SSIM, CVA, texture features,
4
  adaptive thresholding, vegetation/shadow suppression, SNR-weighted fusion,
5
+ SIFT+FLANN registration, tile-based + multi-scale processing, Excess Green
6
+ vegetation index, confidence maps, and improved object classification.
7
  """
8
+ import logging
9
  import numpy as np
10
  import cv2
11
  from PIL import Image
 
13
  from sklearn.preprocessing import StandardScaler
14
  from collections import Counter
15
 
16
+ _log = logging.getLogger(__name__)
17
+
18
 
19
  # ---------------------------------------------------------------------------
20
  # 1. Pre-processing
21
  # ---------------------------------------------------------------------------
22
 
23
+ def _ensure_rgb_uint8(img_array):
24
+ """Convert any image array to 3-channel RGB uint8."""
 
25
  if img_array.ndim == 2:
26
  img_array = cv2.cvtColor(img_array, cv2.COLOR_GRAY2RGB)
27
  elif img_array.ndim == 3 and img_array.shape[2] == 4:
28
  img_array = cv2.cvtColor(img_array, cv2.COLOR_RGBA2RGB)
29
  elif img_array.ndim != 3 or img_array.shape[2] != 3:
30
  raise ValueError(f"Unsupported image shape: {img_array.shape}")
31
+ if img_array.dtype != np.uint8:
32
+ img_array = np.clip(img_array, 0, 255).astype(np.uint8)
33
+ return img_array
34
+
35
+
36
+ def _to_float32(img):
37
+ """Normalize uint8 image to float32 [0,1]."""
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
+
46
  height, width = img_array.shape[:2]
47
  if max(height, width) > max_size:
48
  scale = max_size / max(height, width)
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
 
 
60
  # 2. Improved image registration (alignment)
61
  # ---------------------------------------------------------------------------
62
 
63
+ def _match_features_sift(gray1, gray2):
64
+ """SIFT + FLANN matching with Lowe's ratio test. Returns (homography, inlier_ratio) or (None, 0)."""
65
+ try:
66
+ sift = cv2.SIFT_create(nfeatures=4000)
67
+ kp1, des1 = sift.detectAndCompute(gray1, None)
68
+ kp2, des2 = sift.detectAndCompute(gray2, None)
69
+
70
+ if des1 is None or des2 is None or len(des1) < 10 or len(des2) < 10:
71
+ return None, 0.0
72
+
73
+ FLANN_INDEX_KDTREE = 1
74
+ index_params = dict(algorithm=FLANN_INDEX_KDTREE, trees=5)
75
+ search_params = dict(checks=100)
76
+ flann = cv2.FlannBasedMatcher(index_params, search_params)
77
+ raw_matches = flann.knnMatch(des1, des2, k=2)
78
+
79
+ good = [m for m, n in raw_matches if len([m, n]) == 2 and m.distance < 0.7 * n.distance]
80
+ if len(good) < 8:
81
+ return None, 0.0
82
+
83
+ src = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
84
+ dst = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
85
+ H, mask = cv2.findHomography(dst, src, cv2.RANSAC, 4.0, maxIters=3000)
86
+ if H is None or mask is None:
87
+ return None, 0.0
88
+ det = np.linalg.det(H[:2, :2])
89
+ if abs(det) < 0.1 or abs(det) > 10.0:
90
+ return None, 0.0
91
+ return H, float(np.sum(mask)) / len(mask)
92
+ except Exception:
93
+ return None, 0.0
94
 
 
 
 
95
 
96
+ def _match_features_orb(gray1, gray2, max_features=3000):
97
+ """ORB fallback matching. Returns (homography, inlier_ratio) or (None, 0)."""
98
+ best_H, best_ir = None, 0.0
99
+ for nf, ratio_thr in [(max_features, 0.75), (max_features * 2, 0.80)]:
100
+ orb = cv2.ORB_create(nfeatures=nf, scoreType=cv2.ORB_HARRIS_SCORE,
101
+ edgeThreshold=15, patchSize=31)
102
+ kp1, des1 = orb.detectAndCompute(gray1, None)
103
+ kp2, des2 = orb.detectAndCompute(gray2, None)
104
+ if des1 is None or des2 is None or len(des1) < 10 or len(des2) < 10:
105
+ continue
106
+ bf = cv2.BFMatcher(cv2.NORM_HAMMING)
107
+ raw_matches = bf.knnMatch(des1, des2, k=2)
108
+ good = [m for m, n in raw_matches if len([m, n]) == 2 and m.distance < ratio_thr * n.distance]
109
+ if len(good) < 8:
110
+ continue
111
+ src = np.float32([kp1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2)
112
+ dst = np.float32([kp2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2)
113
+ H, mask = cv2.findHomography(dst, src, cv2.RANSAC, 4.0, maxIters=2000)
114
+ if H is None or mask is None:
115
+ continue
116
+ det = np.linalg.det(H[:2, :2])
117
+ if abs(det) < 0.1 or abs(det) > 10.0:
118
+ continue
119
+ ir = float(np.sum(mask)) / len(mask)
120
+ if ir > best_ir:
121
+ best_H, best_ir = H, ir
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)
 
 
 
 
137
 
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):
161
+ """Refine an already-coarse-aligned image with ECC translation/affine."""
162
+ try:
163
+ gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
164
+ gray2 = cv2.cvtColor(img2_initial, cv2.COLOR_RGB2GRAY).astype(np.float32) / 255.0
165
+ h, w = img1.shape[:2]
166
 
167
+ warp = np.eye(2, 3, dtype=np.float32)
168
+ criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 100, 1e-5)
169
+
170
+ # Try affine first, fall back to translation
171
+ for motion in [cv2.MOTION_AFFINE, cv2.MOTION_TRANSLATION]:
172
+ try:
173
+ warp_m = np.eye(2, 3, dtype=np.float32)
174
+ cc, warp_m = cv2.findTransformECC(
175
+ gray1, gray2, warp_m, motion, criteria)
176
+ if cc >= 0.6:
177
+ aligned = cv2.warpAffine(
178
+ img2_initial, warp_m, (w, h),
179
+ flags=cv2.INTER_LINEAR + cv2.WARP_INVERSE_MAP,
180
+ borderMode=cv2.BORDER_REFLECT)
181
+ return aligned
182
+ except Exception:
183
+ continue
184
+ except Exception:
185
+ pass
186
+ return img2_initial
187
+
188
+
189
+ def _register_images_ecc_multiscale(img1, img2):
190
  """
191
+ Multi-scale ECC fallback: start from a downscaled version (faster, wider
192
+ convergence basin), then refine at full resolution.
193
  """
194
  try:
195
  gray1 = cv2.cvtColor(img1, cv2.COLOR_RGB2GRAY)
196
  gray2 = cv2.cvtColor(img2, cv2.COLOR_RGB2GRAY)
197
+ h, w = img1.shape[:2]
 
198
 
199
+ # Build 2-level pyramid
200
+ scales = [4, 2, 1]
201
  warp = np.eye(2, 3, dtype=np.float32)
202
+
203
+ for scale in scales:
204
+ sh, sw = h // scale, w // scale
205
+ if sh < 64 or sw < 64:
206
+ continue
207
+ g1 = cv2.resize(gray1, (sw, sh)).astype(np.float32) / 255.0
208
+ g2 = cv2.resize(gray2, (sw, sh)).astype(np.float32) / 255.0
209
+
210
+ scaled_warp = warp.copy()
211
+ scaled_warp[0, 2] /= (scales[0] / scale) if scale != scales[0] else 1
212
+ scaled_warp[1, 2] /= (scales[0] / scale) if scale != scales[0] else 1
213
+
214
+ iters = 300 if scale == scales[0] else 150
215
+ criteria = (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, iters, 1e-6)
216
+ try:
217
+ cc, scaled_warp = cv2.findTransformECC(
218
+ g1, g2, scaled_warp, cv2.MOTION_AFFINE, criteria)
219
+ except Exception:
220
+ continue
221
+
222
+ # Scale translation back for next level
223
+ if scale != 1:
224
+ warp = scaled_warp.copy()
225
+ next_idx = scales.index(scale) + 1
226
+ if next_idx < len(scales):
227
+ next_scale = scales[next_idx]
228
+ ratio = scale / next_scale
229
+ warp[0, 2] *= ratio
230
+ warp[1, 2] *= ratio
231
+ else:
232
+ warp = scaled_warp
233
+
234
  aligned = cv2.warpAffine(
235
+ img2, warp, (w, h),
 
 
236
  flags=cv2.INTER_LINEAR + cv2.WARP_INVERSE_MAP,
237
+ borderMode=cv2.BORDER_REFLECT)
238
+
239
+ # Check alignment quality via normalized cross-correlation
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
 
 
279
  # 4. Vegetation suppression
280
  # ---------------------------------------------------------------------------
281
 
282
+ def compute_excess_green(img):
283
+ """
284
+ Excess Green Index: ExG = 2G - R - B (normalized to [0,1]).
285
+ Excellent for separating vegetation from soil/buildings in satellite imagery.
286
+ """
287
+ r = img[:, :, 0].astype(np.float32)
288
+ g = img[:, :, 1].astype(np.float32)
289
+ b = img[:, :, 2].astype(np.float32)
290
+ total = r + g + b + 1e-6
291
+ rn, gn, bn = r / total, g / total, b / total
292
+ exg = 2.0 * gn - rn - bn
293
+ return np.clip(exg, 0, 1).astype(np.float32)
294
+
295
+
296
  def compute_vegetation_mask(img):
297
  """
298
+ Identify vegetation pixels using three complementary indices:
299
+ 1. Pseudo-NDVI (G-R)/(G+R)
300
+ 2. Excess Green Index: ExG = 2G - R - B
301
+ 3. HSV hue/saturation ranges
302
  Returns a float map in [0, 1] where 1.0 = vegetation, 0.0 = non-vegetation.
303
  """
304
  r = img[:, :, 0].astype(np.float32)
305
  g = img[:, :, 1].astype(np.float32)
306
  ndvi = (g - r) / (g + r + 1e-6)
307
 
308
+ exg = compute_excess_green(img)
309
+
310
  hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)
311
  hue = hsv[:, :, 0].astype(np.float32)
312
  sat = hsv[:, :, 1].astype(np.float32)
313
 
314
  ndvi_veg = (ndvi > 0.08).astype(np.float32)
315
+ exg_veg = (exg > 0.05).astype(np.float32)
316
  hsv_veg = ((hue >= 35) & (hue <= 85) & (sat > 30)).astype(np.float32)
317
 
318
+ veg = np.clip(ndvi_veg * 0.4 + exg_veg * 0.3 + hsv_veg * 0.3, 0, 1)
319
  veg = cv2.GaussianBlur(veg, (11, 11), 0)
320
  return veg
321
 
 
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
 
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):
 
819
  return final_mask, debug
820
 
821
 
822
+ # ---------------------------------------------------------------------------
823
+ # 10b. Hybrid AI method (deep learning + classical with confidence map)
824
+ # ---------------------------------------------------------------------------
825
+
826
+ def _build_confidence_map_from_channels(img1, img2, dl_score=None):
827
+ """
828
+ Build a per-pixel confidence map from multiple signal channels.
829
+ Includes color, SSIM, texture, edge, CVA, and optionally a DL score map.
830
+ Returns float32 map in [0,1].
831
+ """
832
+ from .models.model_utils import build_confidence_map
833
+
834
+ color = compute_cva(img1, img2)
835
+ ssim = compute_ssim_change_map(img1, img2)
836
+ ssim_norm = ssim / (ssim.max() + 1e-8)
837
+ texture = compute_texture_change(img1, img2)
838
+ texture_norm = texture / (texture.max() + 1e-8)
839
+ edge = compute_edge_change(img1, img2)
840
+
841
+ channels = [color, ssim_norm.astype(np.float32), texture_norm.astype(np.float32), edge]
842
+ weights = [0.30, 0.25, 0.15, 0.10]
843
+
844
+ if dl_score is not None:
845
+ channels.append(dl_score)
846
+ weights.append(0.40)
847
+ # Re-normalize so weights sum to 1
848
+ total = sum(weights)
849
+ weights = [w / total for w in weights]
850
+
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 .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 .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
  # ---------------------------------------------------------------------------
938
  # 11. Robust post-processing
939
  # ---------------------------------------------------------------------------
 
958
  mask[:, :border_margin] = 0
959
  mask[:, -border_margin:] = 0
960
 
961
+ mask = cv2.medianBlur(mask, 3)
962
 
963
+ open_size = max(3, int(4 * (1 - sensitivity * 0.5)))
964
  if open_size % 2 == 0:
965
  open_size += 1
966
  k_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (open_size, open_size))
967
  mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k_open)
968
 
969
+ close_size = max(3, int(5 * (1 - sensitivity)))
970
  if close_size % 2 == 0:
971
  close_size += 1
972
  k_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (close_size, close_size))
 
1139
 
1140
  # Vegetation indices
1141
  ndvi = (mean_rgb[1] - mean_rgb[0]) / (mean_rgb[1] + mean_rgb[0] + 1e-6)
1142
+ exg = float(np.mean(compute_excess_green(region)))
1143
 
1144
  # Texture
1145
  texture_std = float(np.std(gray))
 
1165
 
1166
  return {
1167
  "mean_rgb": mean_rgb, "std_rgb": std_rgb, "mean_hsv": mean_hsv, "mean_lab": mean_lab,
1168
+ "ndvi": ndvi, "exg": exg,
1169
+ "texture_std": texture_std, "lbp_variance": lbp_variance,
1170
  "edge_density": edge_density, "orientation_entropy": orientation_entropy,
1171
  "glcm_contrast": glcm_contrast,
1172
  "color_homogeneity": float(np.mean(std_rgb)),
 
1288
  return {
1289
  "before": feat_b, "after": feat_a,
1290
  "delta_ndvi": feat_a["ndvi"] - feat_b["ndvi"],
1291
+ "delta_exg": feat_a["exg"] - feat_b["exg"],
1292
  "delta_green_ratio": feat_a["green_ratio"] - feat_b["green_ratio"],
1293
  "delta_edge_density": feat_a["edge_density"] - feat_b["edge_density"],
1294
  "delta_brightness": feat_a["brightness"] - feat_b["brightness"],
 
1371
  if diff:
1372
  # Differential: detect actual vegetation gain or loss
1373
  if abs(diff["delta_ndvi"]) > 0.08:
1374
+ veg += 0.25
1375
+ # Excess Green Index delta — best single indicator of vegetation change
1376
+ if abs(diff.get("delta_exg", 0)) > 0.04:
1377
  veg += 0.20
1378
+ elif abs(diff.get("delta_exg", 0)) > 0.02:
1379
+ veg += 0.10
1380
+ if abs(diff["delta_green_ratio"]) > 0.04:
1381
+ veg += 0.15
1382
  if diff["lab_color_distance"] > 15 and (
1383
  diff["before"]["ndvi"] > 0.05 or diff["after"]["ndvi"] > 0.05):
1384
+ veg += 0.12
1385
  if abs(diff["delta_saturation"]) > 15 and (
1386
  diff["before"]["green_ratio"] > 0.34 or diff["after"]["green_ratio"] > 0.34):
1387
+ veg += 0.12
1388
  if diff["delta_lines"] < 3 and diff["delta_corners"] < 5:
1389
+ veg += 0.06
1390
  if area > 500:
1391
  veg += 0.04
1392
  else:
 
1585
  road += 0.03
1586
  scores["Road/Pavement Change"] = road
1587
 
1588
+ # ---- Temporary Structure (sheds, tents, makeshift) ----
1589
+ tmp = 0.0
1590
+ if diff:
1591
+ ded_t = diff["delta_edge_density"]
1592
+ if 3 < ded_t < 20:
1593
+ tmp += 0.16
1594
+ if diff["delta_lines"] > 0 and diff["delta_lines"] <= 5:
1595
+ tmp += 0.12
1596
+ if diff["delta_corners"] > 0 and diff["delta_corners"] <= 6:
1597
+ tmp += 0.10
1598
+ if diff["hull_ratio_after"] < 0.50:
1599
+ tmp += 0.10
1600
+ if diff["after"]["ndvi"] < 0.08:
1601
+ tmp += 0.08
1602
+ ssim_t = diff.get("ssim", 1.0)
1603
+ if 0.3 < ssim_t < 0.7:
1604
+ tmp += 0.10
1605
+ if diff["lab_color_distance"] > 10:
1606
+ tmp += 0.08
1607
+ if 200 <= area <= 5000:
1608
+ tmp += 0.08
1609
+ if 1.0 <= aspect_ratio <= 3.5:
1610
+ tmp += 0.06
1611
+ else:
1612
+ if feat_a["edge_density"] > 15 and feat_a["edge_density"] < 50:
1613
+ tmp += 0.18
1614
+ if feat_a["orientation_entropy"] > 2.0:
1615
+ tmp += 0.12
1616
+ if feat_a["color_homogeneity"] > 20:
1617
+ tmp += 0.10
1618
+ if feat_a["ndvi"] < 0.08:
1619
+ tmp += 0.12
1620
+ if 200 <= area <= 5000:
1621
+ tmp += 0.10
1622
+ if 1.0 <= aspect_ratio <= 3.5:
1623
+ tmp += 0.08
1624
+ if feat_a["saturation"] < 100:
1625
+ tmp += 0.06
1626
+ scores["Temporary Structure"] = tmp
1627
+
1628
  # ---- Bare Land/Soil Change ----
1629
  soil = 0.0
1630
  if feat_a["red_ratio"] > 0.34 and feat_a["green_ratio"] < 0.36:
 
1646
  best = max(scores, key=scores.get)
1647
  conf = scores[best]
1648
 
1649
+ if conf < 0.22:
1650
  return "Unclassified", conf
1651
  return best, min(conf, 1.0)
1652
 
 
1842
  # ---------------------------------------------------------------------------
1843
 
1844
  _STRUCTURAL_TYPES = {"New Construction/Building", "Demolition/Clearing",
1845
+ "Road/Pavement Change", "Temporary Structure"}
1846
 
1847
 
1848
  def _region_has_structure(crop):
 
2463
  "note": "KMeans clustering path does not use binary threshold.",
2464
  "sensitivity": float(detection_sensitivity),
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
 
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
  )
app/models/__init__.py ADDED
File without changes
app/models/change_model.py ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Siamese U-Net for satellite change detection.
3
+
4
+ A lightweight Siamese encoder shares weights between before/after images,
5
+ fuses features via concatenation + difference, and decodes into a binary
6
+ change probability map.
7
+
8
+ Designed for CPU inference (< 2s per 256x256 tile).
9
+ """
10
+ import logging
11
+ import os
12
+ from pathlib import Path
13
+
14
+ import cv2
15
+ import numpy as np
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+ _MODEL = None
20
+ _DEVICE = None
21
+ _AVAILABLE = None
22
+ _WEIGHTS_DIR = Path(__file__).parent / "weights"
23
+ _WEIGHTS_FILE = _WEIGHTS_DIR / "siamese_unet_cd.pt"
24
+
25
+
26
+ def _try_torch():
27
+ try:
28
+ import torch
29
+ import torch.nn as nn
30
+ return torch, nn
31
+ except ImportError:
32
+ return None, None
33
+
34
+
35
+ # ---------------------------------------------------------------------------
36
+ # Model architecture
37
+ # ---------------------------------------------------------------------------
38
+
39
+ def _build_model():
40
+ torch, nn = _try_torch()
41
+ if torch is None:
42
+ return None
43
+
44
+ class ConvBlock(nn.Module):
45
+ def __init__(self, in_ch, out_ch):
46
+ super().__init__()
47
+ self.block = nn.Sequential(
48
+ nn.Conv2d(in_ch, out_ch, 3, padding=1, bias=False),
49
+ nn.BatchNorm2d(out_ch),
50
+ nn.ReLU(inplace=True),
51
+ nn.Conv2d(out_ch, out_ch, 3, padding=1, bias=False),
52
+ nn.BatchNorm2d(out_ch),
53
+ nn.ReLU(inplace=True),
54
+ )
55
+ def forward(self, x):
56
+ return self.block(x)
57
+
58
+ class Encoder(nn.Module):
59
+ def __init__(self, in_ch=3, base=32):
60
+ super().__init__()
61
+ self.enc1 = ConvBlock(in_ch, base)
62
+ self.enc2 = ConvBlock(base, base * 2)
63
+ self.enc3 = ConvBlock(base * 2, base * 4)
64
+ self.enc4 = ConvBlock(base * 4, base * 8)
65
+ self.pool = nn.MaxPool2d(2)
66
+
67
+ def forward(self, x):
68
+ e1 = self.enc1(x)
69
+ e2 = self.enc2(self.pool(e1))
70
+ e3 = self.enc3(self.pool(e2))
71
+ e4 = self.enc4(self.pool(e3))
72
+ return [e1, e2, e3, e4]
73
+
74
+ class SiameseUNet(nn.Module):
75
+ """
76
+ Siamese U-Net: shared encoder processes before/after images independently.
77
+ Decoder fuses features via concatenation of both streams + their absolute
78
+ difference, providing the decoder with explicit change information.
79
+ """
80
+ def __init__(self, in_ch=3, base=32, out_ch=2):
81
+ super().__init__()
82
+ self.encoder = Encoder(in_ch, base)
83
+ b = base
84
+
85
+ # Decoder: at each level receives [enc_a, enc_b, |enc_a-enc_b|] = 3x channels
86
+ self.up4 = nn.ConvTranspose2d(b * 8, b * 4, 2, stride=2)
87
+ self.dec4 = ConvBlock(b * 4 + b * 4 * 3, b * 4)
88
+
89
+ self.up3 = nn.ConvTranspose2d(b * 4, b * 2, 2, stride=2)
90
+ self.dec3 = ConvBlock(b * 2 + b * 2 * 3, b * 2)
91
+
92
+ self.up2 = nn.ConvTranspose2d(b * 2, b, 2, stride=2)
93
+ self.dec2 = ConvBlock(b + b * 3, b)
94
+
95
+ self.head = nn.Conv2d(b, out_ch, 1)
96
+
97
+ def forward(self, img_a, img_b):
98
+ feats_a = self.encoder(img_a)
99
+ feats_b = self.encoder(img_b)
100
+
101
+ # Bottleneck: fuse deepest features
102
+ bot = torch.cat([feats_a[3], feats_b[3], torch.abs(feats_a[3] - feats_b[3])], dim=1)
103
+
104
+ import torch.nn.functional as F
105
+ # Level 3
106
+ d4 = self.up4(feats_a[3])
107
+ skip3 = torch.cat([feats_a[2], feats_b[2], torch.abs(feats_a[2] - feats_b[2])], dim=1)
108
+ d4 = self.dec4(torch.cat([d4, skip3], dim=1))
109
+
110
+ # Level 2
111
+ d3 = self.up3(d4)
112
+ skip2 = torch.cat([feats_a[1], feats_b[1], torch.abs(feats_a[1] - feats_b[1])], dim=1)
113
+ d3 = self.dec3(torch.cat([d3, skip2], dim=1))
114
+
115
+ # Level 1
116
+ d2 = self.up2(d3)
117
+ skip1 = torch.cat([feats_a[0], feats_b[0], torch.abs(feats_a[0] - feats_b[0])], dim=1)
118
+ d2 = self.dec2(torch.cat([d2, skip1], dim=1))
119
+
120
+ return self.head(d2)
121
+
122
+ return SiameseUNet
123
+
124
+
125
+ # ---------------------------------------------------------------------------
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
+
139
+ def _load_siamese():
140
+ global _MODEL, _DEVICE
141
+ if _MODEL is not None:
142
+ return _MODEL
143
+
144
+ torch, _ = _try_torch()
145
+ if torch is None:
146
+ raise RuntimeError("PyTorch not installed")
147
+
148
+ _DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
149
+
150
+ ModelClass = _build_model()
151
+ model = ModelClass(in_ch=3, base=32, out_ch=2)
152
+
153
+ if _WEIGHTS_FILE.exists():
154
+ logger.info("Loading Siamese U-Net weights from %s", _WEIGHTS_FILE)
155
+ state = torch.load(str(_WEIGHTS_FILE), map_location=_DEVICE, weights_only=True)
156
+ model.load_state_dict(state)
157
+ else:
158
+ logger.info("No pretrained weights found at %s — using random init "
159
+ "(model will still produce change maps but accuracy depends on "
160
+ "classical fusion weighting)", _WEIGHTS_FILE)
161
+
162
+ model.to(_DEVICE)
163
+ model.eval()
164
+ _MODEL = model
165
+ return _MODEL
166
+
167
+
168
+ # ---------------------------------------------------------------------------
169
+ # Inference
170
+ # ---------------------------------------------------------------------------
171
+
172
+ _TILE = 256
173
+
174
+
175
+ def predict_siamese(img1, img2, threshold=0.5):
176
+ """
177
+ Run Siamese U-Net inference on two RGB uint8 arrays.
178
+ Tile-based with overlap stitching (same pattern as AdaptFormer).
179
+ Returns (uint8 mask [0|255], float32 probability map [0-1]).
180
+ """
181
+ torch, _ = _try_torch()
182
+ model = _load_siamese()
183
+
184
+ if img1.shape != img2.shape:
185
+ img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
186
+
187
+ h, w = img1.shape[:2]
188
+ tile = _TILE
189
+ overlap = tile // 4
190
+ stride = tile - overlap
191
+
192
+ pad_h = (tile - h % tile) % tile
193
+ pad_w = (tile - w % tile) % tile
194
+ if pad_h or pad_w:
195
+ img1 = np.pad(img1, ((0, pad_h), (0, pad_w), (0, 0)), mode="reflect")
196
+ img2 = np.pad(img2, ((0, pad_h), (0, pad_w), (0, 0)), mode="reflect")
197
+
198
+ ph, pw = img1.shape[:2]
199
+ score_sum = np.zeros((ph, pw), dtype=np.float32)
200
+ count = np.zeros((ph, pw), dtype=np.float32)
201
+
202
+ ramp = np.linspace(0, 1, overlap)
203
+ flat = np.ones(tile - 2 * overlap)
204
+ profile = np.concatenate([ramp, flat, ramp[::-1]])
205
+ weight_2d = np.outer(profile, profile).astype(np.float32)
206
+
207
+ mean = np.array([0.485, 0.456, 0.406], dtype=np.float32)
208
+ std = np.array([0.229, 0.224, 0.225], dtype=np.float32)
209
+
210
+ with torch.no_grad():
211
+ for y0 in range(0, ph - tile + 1, stride):
212
+ for x0 in range(0, pw - tile + 1, stride):
213
+ t1 = img1[y0:y0+tile, x0:x0+tile].astype(np.float32) / 255.0
214
+ t2 = img2[y0:y0+tile, x0:x0+tile].astype(np.float32) / 255.0
215
+
216
+ t1 = (t1 - mean) / std
217
+ t2 = (t2 - mean) / std
218
+
219
+ ta = torch.from_numpy(t1.transpose(2, 0, 1)).unsqueeze(0).to(_DEVICE)
220
+ tb = torch.from_numpy(t2.transpose(2, 0, 1)).unsqueeze(0).to(_DEVICE)
221
+
222
+ logits = model(ta, tb)
223
+ probs = torch.softmax(logits, dim=1)
224
+ prob_map = probs[0, 1].cpu().numpy()
225
+
226
+ if prob_map.shape != (tile, tile):
227
+ prob_map = cv2.resize(prob_map, (tile, tile))
228
+
229
+ score_sum[y0:y0+tile, x0:x0+tile] += prob_map * weight_2d
230
+ count[y0:y0+tile, x0:x0+tile] += weight_2d
231
+
232
+ count = np.maximum(count, 1e-6)
233
+ avg = score_sum / count
234
+ avg = avg[:h, :w]
235
+
236
+ mask = (avg >= threshold).astype(np.uint8) * 255
237
+ return mask, avg
app/models/model_utils.py ADDED
@@ -0,0 +1,108 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Shared utilities for model loading, tile processing, and multi-scale detection.
3
+ """
4
+ import cv2
5
+ import numpy as np
6
+
7
+
8
+ def split_into_tiles(img, tile_size=512, overlap=64):
9
+ """
10
+ Split an image into overlapping tiles.
11
+ Returns list of (tile, y_offset, x_offset) tuples.
12
+ """
13
+ h, w = img.shape[:2]
14
+ stride = tile_size - overlap
15
+ tiles = []
16
+
17
+ pad_h = (tile_size - h % tile_size) % tile_size if h % tile_size else 0
18
+ pad_w = (tile_size - w % tile_size) % tile_size if w % tile_size else 0
19
+ if pad_h or pad_w:
20
+ img = np.pad(img, ((0, pad_h), (0, pad_w), (0, 0)) if img.ndim == 3
21
+ else ((0, pad_h), (0, pad_w)), mode="reflect")
22
+
23
+ ph, pw = img.shape[:2]
24
+ for y in range(0, ph - tile_size + 1, stride):
25
+ for x in range(0, pw - tile_size + 1, stride):
26
+ tile = img[y:y+tile_size, x:x+tile_size]
27
+ tiles.append((tile, y, x))
28
+
29
+ return tiles, (ph, pw), (h, w)
30
+
31
+
32
+ def merge_tile_masks(tile_results, padded_shape, orig_shape, tile_size=512, overlap=64):
33
+ """
34
+ Merge tile-level binary masks back into a single full-resolution mask.
35
+ Uses raised-cosine blending to avoid tile boundary artifacts.
36
+ """
37
+ ph, pw = padded_shape
38
+ h, w = orig_shape
39
+
40
+ score_sum = np.zeros((ph, pw), dtype=np.float32)
41
+ count = np.zeros((ph, pw), dtype=np.float32)
42
+
43
+ ramp = np.linspace(0, 1, overlap)
44
+ flat = np.ones(tile_size - 2 * overlap)
45
+ profile = np.concatenate([ramp, flat, ramp[::-1]])
46
+ weight_2d = np.outer(profile, profile).astype(np.float32)
47
+
48
+ for (mask_tile, y, x) in tile_results:
49
+ score = mask_tile.astype(np.float32) / 255.0 if mask_tile.max() > 1 else mask_tile.astype(np.float32)
50
+ if score.shape != (tile_size, tile_size):
51
+ score = cv2.resize(score, (tile_size, tile_size))
52
+ score_sum[y:y+tile_size, x:x+tile_size] += score * weight_2d
53
+ count[y:y+tile_size, x:x+tile_size] += weight_2d
54
+
55
+ count = np.maximum(count, 1e-6)
56
+ merged = score_sum / count
57
+ merged = merged[:h, :w]
58
+ return (merged * 255).astype(np.uint8)
59
+
60
+
61
+ def multiscale_detect(detect_fn, img1, img2, scales=(1.0, 0.5, 0.25)):
62
+ """
63
+ Run a detection function at multiple scales and combine via logical OR.
64
+ detect_fn(img1, img2) -> uint8 mask [0|255].
65
+ Captures small structures at full res and large regions at coarse scales.
66
+ """
67
+ h, w = img1.shape[:2]
68
+ combined = np.zeros((h, w), dtype=np.uint8)
69
+
70
+ for scale in scales:
71
+ if scale == 1.0:
72
+ s1, s2 = img1, img2
73
+ else:
74
+ sh, sw = max(64, int(h * scale)), max(64, int(w * scale))
75
+ s1 = cv2.resize(img1, (sw, sh), interpolation=cv2.INTER_AREA)
76
+ s2 = cv2.resize(img2, (sw, sh), interpolation=cv2.INTER_AREA)
77
+
78
+ mask = detect_fn(s1, s2)
79
+
80
+ if scale != 1.0:
81
+ mask = cv2.resize(mask, (w, h), interpolation=cv2.INTER_NEAREST)
82
+
83
+ combined = np.maximum(combined, mask)
84
+
85
+ return combined
86
+
87
+
88
+ def build_confidence_map(channels, weights=None):
89
+ """
90
+ Build a [0-1] confidence map from multiple normalized signal channels.
91
+ Each channel should be a float32 array in [0,1].
92
+ If weights is None, uses equal weighting.
93
+ """
94
+ if not channels:
95
+ return None
96
+ if weights is None:
97
+ weights = [1.0 / len(channels)] * len(channels)
98
+ total_w = sum(weights)
99
+ weights = [w / total_w for w in weights]
100
+
101
+ shape = channels[0].shape
102
+ conf = np.zeros(shape, dtype=np.float64)
103
+ for ch, w in zip(channels, weights):
104
+ if ch.shape != shape:
105
+ ch = cv2.resize(ch.astype(np.float32), (shape[1], shape[0]))
106
+ conf += w * ch.astype(np.float64)
107
+
108
+ return np.clip(conf, 0, 1).astype(np.float32)
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=23" />
8
  </head>
9
  <body>
10
  <div class="app">
@@ -210,6 +210,7 @@
210
  <option value="Image Difference">Image Difference</option>
211
  <option value="Feature-Based">Feature-Based</option>
212
  <option value="Hybrid Approach">Hybrid Approach</option>
 
213
  </select>
214
  </div>
215
  <div class="form-group checkbox-group">
@@ -360,6 +361,6 @@
360
  </div>
361
  </div>
362
 
363
- <script src="/static/js/app.js?v=38"></script>
364
  </body>
365
  </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=25" />
8
  </head>
9
  <body>
10
  <div class="app">
 
210
  <option value="Image Difference">Image Difference</option>
211
  <option value="Feature-Based">Feature-Based</option>
212
  <option value="Hybrid Approach">Hybrid Approach</option>
213
+ <option value="Hybrid AI">Hybrid AI (DL + Multi-Scale)</option>
214
  </select>
215
  </div>
216
  <div class="form-group checkbox-group">
 
361
  </div>
362
  </div>
363
 
364
+ <script src="/static/js/app.js?v=40"></script>
365
  </body>
366
  </html>