coderuday21 commited on
Commit
3808a54
·
1 Parent(s): d555eda

Production overhaul: pre-trained AdaptFormer model + detection quality improvements

Browse files
Dockerfile CHANGED
@@ -19,14 +19,21 @@ 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=13
23
  ENV APP_BUILD=${APP_BUILD}
24
  RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
25
 
26
  # Install Python dependencies
27
  COPY requirements.txt .
28
- RUN pip install --no-cache-dir --disable-pip-version-check --default-timeout=120 -U pip setuptools wheel
29
- RUN pip install --no-cache-dir --disable-pip-version-check --default-timeout=120 --prefer-binary -r requirements.txt -v
 
 
 
 
 
 
 
30
 
31
  # Copy application code
32
  COPY . .
 
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=14
23
  ENV APP_BUILD=${APP_BUILD}
24
  RUN echo "Docker build start: APP_BUILD=${APP_BUILD}" && python -V
25
 
26
  # Install Python dependencies
27
  COPY requirements.txt .
28
+ RUN pip install --no-cache-dir --disable-pip-version-check --default-timeout=300 -U pip setuptools wheel
29
+ RUN pip install --no-cache-dir --disable-pip-version-check --default-timeout=300 --prefer-binary -r requirements.txt -v
30
+
31
+ # Pre-download the AdaptFormer model so cold starts are instant
32
+ ENV HF_HOME=/app/.hf_cache
33
+ RUN python -c "from transformers import AutoImageProcessor, AutoModel; \
34
+ AutoImageProcessor.from_pretrained('deepang/adaptformer-LEVIR-CD', cache_dir='/app/.hf_cache'); \
35
+ AutoModel.from_pretrained('deepang/adaptformer-LEVIR-CD', cache_dir='/app/.hf_cache'); \
36
+ print('Model pre-downloaded successfully')"
37
 
38
  # Copy application code
39
  COPY . .
app/detection_engine.py CHANGED
@@ -177,15 +177,18 @@ def compute_vegetation_mask(img):
177
 
178
  def compute_combined_vegetation_suppression(img1, img2):
179
  """
180
- Build a suppression map from both images: if EITHER image shows vegetation
181
- in a region, dampen it. Returns a float map in [0, 1] where
182
- 1.0 = no suppression, ~0.3 = heavy suppression (vegetation area).
 
183
  """
184
  veg1 = compute_vegetation_mask(img1)
185
  veg2 = compute_vegetation_mask(img2)
186
- combined_veg = np.maximum(veg1, veg2)
187
- suppression = 1.0 - combined_veg * 0.7
188
- return suppression.astype(np.float32)
 
 
189
 
190
 
191
  # ---------------------------------------------------------------------------
@@ -584,24 +587,29 @@ def _ai_fusion_core(img1, img2, sensitivity=0.5):
584
 
585
  def ai_deep_learning_method(img1, img2, sensitivity=0.5):
586
  """
587
- Uses the trained Siamese U-Net when available; falls back to the
588
  rule-based multi-channel fusion otherwise.
589
  """
590
  from .model_inference import is_model_available, predict_change_mask
591
 
592
  if is_model_available():
593
  threshold = 0.35 + (1.0 - sensitivity) * 0.3
594
- change_mask, score_map = predict_change_mask(img1, img2, threshold=threshold)
595
- change_mask = _clean_mask(change_mask, sensitivity=sensitivity)
596
- debug = {
597
- "method": "AI-Based Deep Learning (Siamese U-Net)",
598
- "model": "siamese_unet",
599
- "threshold_used": int(threshold * 255),
600
- "sensitivity": float(sensitivity),
601
- }
602
- return change_mask, debug
 
 
 
 
 
 
603
 
604
- # Fallback: rule-based fusion
605
  change_mask, core_debug = _ai_fusion_core(img1, img2, sensitivity=sensitivity)
606
  debug = {
607
  "method": "AI-Based Deep Learning (rule-based fallback)",
@@ -723,16 +731,33 @@ def _clean_mask(mask, sensitivity=0.5, border_margin=12):
723
 
724
  def _severity_from_region(region, total_pixels):
725
  """
726
- Classify change severity from area and confidence.
727
- Green = minor, Yellow = moderate, Red = major.
728
- Area is the primary signal; confidence acts as a small bonus.
729
  """
730
  area = region.get("area", 0)
731
  confidence = region.get("confidence", 0.0)
 
732
  if total_pixels <= 0:
733
  return "minor"
734
  area_ratio = area / total_pixels
735
- # Area-dominant score: area ratio (0-1) mapped to 0-10, confidence adds 0-0.3
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
736
  score = area_ratio * 1000 + confidence * 0.3
737
  if score < 1.0:
738
  return "minor"
@@ -900,11 +925,91 @@ def _is_transient_object(area, w, h, features):
900
  return False
901
 
902
 
903
- def classify_object_type(image_region, bbox):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
904
  """
905
- Classify GROUND-LEVEL structural changes only.
906
- Categories: construction, demolition, vegetation, water, road, bare land.
907
- Transient objects (people, cars, animals) are filtered out.
908
  """
909
  x, y, w, h = bbox
910
  pad = 5
@@ -912,119 +1017,175 @@ def classify_object_type(image_region, bbox):
912
  y2 = min(image_region.shape[0], y + h + pad)
913
  x1 = max(0, x - pad)
914
  x2 = min(image_region.shape[1], x + w + pad)
915
- region = image_region[y1:y2, x1:x2]
916
 
917
- if region.size == 0 or region.shape[0] < 3 or region.shape[1] < 3:
918
  return "Unclassified", 0.0
919
 
920
- features = extract_advanced_features(region)
921
- if features is None:
922
  return "Unclassified", 0.0
923
 
924
  area = w * h
925
-
926
- # Filter out transient objects (people, cars, animals)
927
- if _is_transient_object(area, w, h, features):
928
- return None, 0.0 # signal to exclude this region
929
 
930
  aspect_ratio = max(w, h) / max(min(w, h), 1)
931
  compactness = (4 * np.pi * area) / ((2 * (w + h)) ** 2 + 1e-6)
932
 
 
 
 
 
 
 
 
 
 
 
 
933
  scores = {}
934
 
935
  # ---- Water Body Change ----
936
  water = 0.0
937
- if features["blue_ratio"] > 0.36:
938
  water += 0.22
939
- if features["texture_std"] < 28:
940
  water += 0.18
941
- if features["edge_density"] < 35:
942
  water += 0.14
943
- if 90 <= features["hue"] <= 135:
944
  water += 0.18
945
- if features["lbp_variance"] < 0.05:
946
  water += 0.14
947
- if features["glcm_contrast"] < 500:
948
  water += 0.10
949
  if area > 800:
950
  water += 0.04
951
  scores["Water Body Change"] = water
952
 
953
- # ---- Vegetation Change (deforestation, new growth, crop change) ----
954
  veg = 0.0
955
- if features["ndvi"] > 0.05:
956
- veg += 0.22
957
- if features["ndvi"] > 0.15:
958
- veg += 0.10
959
- if features["green_ratio"] > 0.36:
960
- veg += 0.18
961
- if 35 <= features["hue"] <= 85:
962
- veg += 0.15
963
- if features["texture_std"] > 18:
964
- veg += 0.08
965
- if features["lbp_variance"] > 0.03:
966
- veg += 0.08
967
- if features["saturation"] > 40:
968
- veg += 0.10
969
- if features["orientation_entropy"] > 2.5:
970
- veg += 0.05
971
- if area > 500:
972
- veg += 0.04
 
 
 
 
 
 
 
 
 
 
 
 
 
973
  scores["Vegetation Change"] = veg
974
 
975
  # ---- New Construction/Building ----
976
  bld = 0.0
977
- if features["orientation_entropy"] < 2.5:
978
- bld += 0.18
979
- if features["color_homogeneity"] < 28:
980
- bld += 0.15
981
- if 1.0 <= aspect_ratio <= 4.0:
982
- bld += 0.12
983
- if 0.3 <= compactness <= 0.9:
984
- bld += 0.10
985
- if features["edge_density"] > 30:
986
- bld += 0.12
987
- if features["glcm_contrast"] > 400:
988
- bld += 0.10
989
- if features["saturation"] < 90:
990
- bld += 0.10
991
- if 40 <= features["brightness"] <= 90:
992
- bld += 0.08
993
- if area > 1000:
994
- bld += 0.05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
995
  scores["New Construction/Building"] = bld
996
 
997
  # ---- Demolition/Clearing ----
998
  demo = 0.0
999
- if features["texture_std"] > 30:
1000
- demo += 0.18
1001
- if features["orientation_entropy"] > 2.8:
1002
- demo += 0.15
1003
- if features["color_homogeneity"] > 25:
1004
- demo += 0.15
1005
- if features["brightness"] > 60:
1006
- demo += 0.10
1007
- if features["ndvi"] < 0.05:
1008
- demo += 0.12
1009
- if features["saturation"] < 70:
1010
- demo += 0.10
1011
- if area > 800:
1012
- demo += 0.05
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1013
  scores["Demolition/Clearing"] = demo
1014
 
1015
  # ---- Road/Pavement Change ----
1016
  road = 0.0
1017
  if aspect_ratio > 2.5:
1018
  road += 0.22
1019
- if features["color_homogeneity"] < 22:
1020
  road += 0.18
1021
- if features["texture_std"] < 32:
1022
  road += 0.15
1023
- if features["saturation"] < 65:
1024
  road += 0.12
1025
- if features["orientation_entropy"] < 2.0:
1026
  road += 0.15
1027
- if 35 <= features["brightness"] <= 75:
1028
  road += 0.10
1029
  if compactness < 0.3:
1030
  road += 0.05
@@ -1034,24 +1195,22 @@ def classify_object_type(image_region, bbox):
1034
 
1035
  # ---- Bare Land/Soil Change ----
1036
  soil = 0.0
1037
- if features["red_ratio"] > 0.34 and features["green_ratio"] < 0.36:
1038
  soil += 0.20
1039
- if 8 <= features["hue"] <= 38:
1040
  soil += 0.18
1041
- if features["ndvi"] < 0.05:
1042
  soil += 0.18
1043
- if features["texture_std"] < 35:
1044
  soil += 0.12
1045
- if features["lbp_variance"] < 0.04:
1046
  soil += 0.12
1047
- if 40 <= features["saturation"] <= 130:
1048
  soil += 0.10
1049
- if 45 <= features["brightness"] <= 82:
1050
  soil += 0.10
1051
  scores["Bare Land/Soil Change"] = soil
1052
 
1053
- # Use raw scores as confidence (each rule set sums to ~1.0 max)
1054
- # Do NOT normalize by max_score — that inflates weak matches to 1.0
1055
  best = max(scores, key=scores.get)
1056
  conf = scores[best]
1057
 
@@ -1060,10 +1219,10 @@ def classify_object_type(image_region, bbox):
1060
  return best, min(conf, 1.0)
1061
 
1062
 
1063
- def classify_with_ensemble(image_region, bbox):
1064
  """Ensemble: classify full region + sub-regions, vote with confidence weighting."""
1065
  x, y, w, h = bbox
1066
- sub_boxes = [(x, y, w, h)] # full region
1067
 
1068
  if w > 20 and h > 20:
1069
  hw, hh = w // 2, h // 2
@@ -1079,7 +1238,8 @@ def classify_with_ensemble(image_region, bbox):
1079
  confidences = []
1080
  transient_count = 0
1081
  for sb in sub_boxes:
1082
- obj_type, conf = classify_object_type(image_region, sb)
 
1083
  if obj_type is None:
1084
  transient_count += 1
1085
  continue
@@ -1087,14 +1247,13 @@ def classify_with_ensemble(image_region, bbox):
1087
  classifications.append(obj_type)
1088
  confidences.append(conf)
1089
 
1090
- # Only exclude if majority of sub-regions are transient
1091
  if transient_count > len(sub_boxes) // 2:
1092
  return None, 0.0
1093
 
1094
  if not classifications:
1095
- return classify_object_type(image_region, (x, y, w, h))
 
1096
 
1097
- # Weighted voting
1098
  weighted = {}
1099
  counts = Counter(classifications)
1100
  for ot, c in zip(classifications, confidences):
@@ -1767,9 +1926,11 @@ def analyze_change_regions(change_mask, image, min_area=400, use_ensemble=True,
1767
  cx, cy = centroids[i]
1768
 
1769
  if use_ensemble and raw_area > 500:
1770
- object_type, confidence = classify_with_ensemble(image, (x, y, w, h))
 
1771
  else:
1772
- object_type, confidence = classify_object_type(image, (x, y, w, h))
 
1773
 
1774
  if object_type is None:
1775
  # Do not silently drop large coherent regions; keep them as generic
 
177
 
178
  def compute_combined_vegetation_suppression(img1, img2):
179
  """
180
+ Asymmetric vegetation handling:
181
+ - Where BOTH images are vegetated: suppress (likely seasonal noise)
182
+ - Where only ONE image is vegetated: boost (real vegetation change)
183
+ Returns a float map where 1.0 = neutral, <1 = suppress, >1 = boost.
184
  """
185
  veg1 = compute_vegetation_mask(img1)
186
  veg2 = compute_vegetation_mask(img2)
187
+ both_veg = np.minimum(veg1, veg2)
188
+ one_only = np.abs(veg1 - veg2)
189
+ seasonal_suppression = 1.0 - both_veg * 0.7
190
+ vegetation_boost = 1.0 + one_only * 0.3
191
+ return (seasonal_suppression * vegetation_boost).astype(np.float32)
192
 
193
 
194
  # ---------------------------------------------------------------------------
 
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.35 + (1.0 - sensitivity) * 0.3
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)",
 
731
 
732
  def _severity_from_region(region, total_pixels):
733
  """
734
+ Type-aware severity classification.
735
+ Building/structural changes use area + confidence.
736
+ Vegetation changes weight confidence (NDVI delta) more heavily.
737
  """
738
  area = region.get("area", 0)
739
  confidence = region.get("confidence", 0.0)
740
+ obj_type = region.get("object_type", "")
741
  if total_pixels <= 0:
742
  return "minor"
743
  area_ratio = area / total_pixels
744
+
745
+ if obj_type in _VEGETATION_TYPES or "Vegetation" in (obj_type or ""):
746
+ score = area_ratio * 600 + confidence * 0.6
747
+ if score < 0.8:
748
+ return "minor"
749
+ if score < 3.0:
750
+ return "moderate"
751
+ return "major"
752
+
753
+ if obj_type in _STRUCTURAL_TYPES or obj_type in _BUILDING_TYPES:
754
+ score = area_ratio * 1200 + confidence * 0.4
755
+ if score < 1.2:
756
+ return "minor"
757
+ if score < 4.5:
758
+ return "moderate"
759
+ return "major"
760
+
761
  score = area_ratio * 1000 + confidence * 0.3
762
  if score < 1.0:
763
  return "minor"
 
925
  return False
926
 
927
 
928
+ def _count_line_segments(gray_crop):
929
+ """Count straight line segments using LSD — buildings have many, vegetation has few."""
930
+ if gray_crop.size == 0 or gray_crop.shape[0] < 5 or gray_crop.shape[1] < 5:
931
+ return 0, 0.0
932
+ lsd = cv2.createLineSegmentDetector(0)
933
+ lines, _, _, _ = lsd.detect(gray_crop.astype(np.uint8))
934
+ if lines is None:
935
+ return 0, 0.0
936
+ n_lines = len(lines)
937
+ total_length = sum(
938
+ np.sqrt((l[0][2] - l[0][0])**2 + (l[0][3] - l[0][1])**2)
939
+ for l in lines
940
+ )
941
+ return n_lines, float(total_length)
942
+
943
+
944
+ def _count_corners(gray_crop):
945
+ """Count strong corners — buildings have clustered grid-like corners."""
946
+ if gray_crop.size == 0 or gray_crop.shape[0] < 5 or gray_crop.shape[1] < 5:
947
+ return 0
948
+ corners = cv2.goodFeaturesToTrack(
949
+ gray_crop.astype(np.uint8), maxCorners=100,
950
+ qualityLevel=0.05, minDistance=5)
951
+ return 0 if corners is None else len(corners)
952
+
953
+
954
+ def _rectangular_hull_ratio(gray_crop, threshold=128):
955
+ """Ratio of non-zero area to bounding rect — buildings fill their box."""
956
+ if gray_crop.size == 0:
957
+ return 0.0
958
+ _, binary = cv2.threshold(gray_crop.astype(np.uint8), threshold, 255, cv2.THRESH_BINARY)
959
+ contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
960
+ if not contours:
961
+ return 0.0
962
+ biggest = max(contours, key=cv2.contourArea)
963
+ contour_area = cv2.contourArea(biggest)
964
+ _, _, rw, rh = cv2.boundingRect(biggest)
965
+ rect_area = max(rw * rh, 1)
966
+ return contour_area / rect_area
967
+
968
+
969
+ def _extract_differential_features(before_crop, after_crop):
970
+ """Extract features from BOTH before and after crops plus their deltas."""
971
+ feat_b = extract_advanced_features(before_crop)
972
+ feat_a = extract_advanced_features(after_crop)
973
+ if feat_b is None or feat_a is None:
974
+ return None
975
+
976
+ gray_b = cv2.cvtColor(before_crop, cv2.COLOR_RGB2GRAY)
977
+ gray_a = cv2.cvtColor(after_crop, cv2.COLOR_RGB2GRAY)
978
+
979
+ lines_b, linelen_b = _count_line_segments(gray_b)
980
+ lines_a, linelen_a = _count_line_segments(gray_a)
981
+ corners_b = _count_corners(gray_b)
982
+ corners_a = _count_corners(gray_a)
983
+ hull_a = _rectangular_hull_ratio(gray_a)
984
+
985
+ lab_b = cv2.cvtColor(before_crop, cv2.COLOR_RGB2LAB).astype(np.float32)
986
+ lab_a = cv2.cvtColor(after_crop, cv2.COLOR_RGB2LAB).astype(np.float32)
987
+ lab_dist = float(np.mean(np.sqrt(np.sum((lab_a - lab_b) ** 2, axis=2))))
988
+
989
+ return {
990
+ "before": feat_b, "after": feat_a,
991
+ "delta_ndvi": feat_a["ndvi"] - feat_b["ndvi"],
992
+ "delta_green_ratio": feat_a["green_ratio"] - feat_b["green_ratio"],
993
+ "delta_edge_density": feat_a["edge_density"] - feat_b["edge_density"],
994
+ "delta_brightness": feat_a["brightness"] - feat_b["brightness"],
995
+ "delta_texture_std": feat_a["texture_std"] - feat_b["texture_std"],
996
+ "delta_saturation": feat_a["saturation"] - feat_b["saturation"],
997
+ "delta_orientation_entropy": feat_a["orientation_entropy"] - feat_b["orientation_entropy"],
998
+ "delta_lines": lines_a - lines_b,
999
+ "delta_line_length": linelen_a - linelen_b,
1000
+ "delta_corners": corners_a - corners_b,
1001
+ "lines_after": lines_a, "corners_after": corners_a,
1002
+ "lines_before": lines_b, "corners_before": corners_b,
1003
+ "hull_ratio_after": hull_a,
1004
+ "lab_color_distance": lab_dist,
1005
+ }
1006
+
1007
+
1008
+ def classify_object_type(image_region, bbox, before_region=None):
1009
  """
1010
+ Classify the type of change in a region.
1011
+ When before_region is provided, uses differential (before vs after) analysis
1012
+ for dramatically better accuracy. Falls back to single-image analysis otherwise.
1013
  """
1014
  x, y, w, h = bbox
1015
  pad = 5
 
1017
  y2 = min(image_region.shape[0], y + h + pad)
1018
  x1 = max(0, x - pad)
1019
  x2 = min(image_region.shape[1], x + w + pad)
1020
+ after_crop = image_region[y1:y2, x1:x2]
1021
 
1022
+ if after_crop.size == 0 or after_crop.shape[0] < 3 or after_crop.shape[1] < 3:
1023
  return "Unclassified", 0.0
1024
 
1025
+ feat_a = extract_advanced_features(after_crop)
1026
+ if feat_a is None:
1027
  return "Unclassified", 0.0
1028
 
1029
  area = w * h
1030
+ if _is_transient_object(area, w, h, feat_a):
1031
+ return None, 0.0
 
 
1032
 
1033
  aspect_ratio = max(w, h) / max(min(w, h), 1)
1034
  compactness = (4 * np.pi * area) / ((2 * (w + h)) ** 2 + 1e-6)
1035
 
1036
+ # --- Differential classification when before image is available ---
1037
+ diff = None
1038
+ if before_region is not None:
1039
+ by1 = max(0, y - pad)
1040
+ by2 = min(before_region.shape[0], y + h + pad)
1041
+ bx1 = max(0, x - pad)
1042
+ bx2 = min(before_region.shape[1], x + w + pad)
1043
+ before_crop = before_region[by1:by2, bx1:bx2]
1044
+ if before_crop.size > 0 and before_crop.shape[0] >= 3 and before_crop.shape[1] >= 3:
1045
+ diff = _extract_differential_features(before_crop, after_crop)
1046
+
1047
  scores = {}
1048
 
1049
  # ---- Water Body Change ----
1050
  water = 0.0
1051
+ if feat_a["blue_ratio"] > 0.36:
1052
  water += 0.22
1053
+ if feat_a["texture_std"] < 28:
1054
  water += 0.18
1055
+ if feat_a["edge_density"] < 35:
1056
  water += 0.14
1057
+ if 90 <= feat_a["hue"] <= 135:
1058
  water += 0.18
1059
+ if feat_a["lbp_variance"] < 0.05:
1060
  water += 0.14
1061
+ if feat_a["glcm_contrast"] < 500:
1062
  water += 0.10
1063
  if area > 800:
1064
  water += 0.04
1065
  scores["Water Body Change"] = water
1066
 
1067
+ # ---- Vegetation Change ----
1068
  veg = 0.0
1069
+ if diff:
1070
+ # Differential: detect actual vegetation gain or loss
1071
+ if abs(diff["delta_ndvi"]) > 0.08:
1072
+ veg += 0.30
1073
+ if abs(diff["delta_green_ratio"]) > 0.04:
1074
+ veg += 0.20
1075
+ if diff["lab_color_distance"] > 15 and (
1076
+ diff["before"]["ndvi"] > 0.05 or diff["after"]["ndvi"] > 0.05):
1077
+ veg += 0.15
1078
+ if abs(diff["delta_saturation"]) > 15 and (
1079
+ diff["before"]["green_ratio"] > 0.34 or diff["after"]["green_ratio"] > 0.34):
1080
+ veg += 0.15
1081
+ if diff["delta_lines"] < 3 and diff["delta_corners"] < 5:
1082
+ veg += 0.08
1083
+ if area > 500:
1084
+ veg += 0.04
1085
+ else:
1086
+ if feat_a["ndvi"] > 0.05:
1087
+ veg += 0.22
1088
+ if feat_a["ndvi"] > 0.15:
1089
+ veg += 0.10
1090
+ if feat_a["green_ratio"] > 0.36:
1091
+ veg += 0.18
1092
+ if 35 <= feat_a["hue"] <= 85:
1093
+ veg += 0.15
1094
+ if feat_a["saturation"] > 40:
1095
+ veg += 0.10
1096
+ if feat_a["orientation_entropy"] > 2.5:
1097
+ veg += 0.05
1098
+ if area > 500:
1099
+ veg += 0.04
1100
  scores["Vegetation Change"] = veg
1101
 
1102
  # ---- New Construction/Building ----
1103
  bld = 0.0
1104
+ if diff:
1105
+ if diff["delta_edge_density"] > 15:
1106
+ bld += 0.20
1107
+ if diff["delta_orientation_entropy"] < -0.4:
1108
+ bld += 0.15
1109
+ if diff["delta_lines"] > 5:
1110
+ bld += 0.15
1111
+ if diff["delta_corners"] > 8:
1112
+ bld += 0.12
1113
+ if diff["after"]["ndvi"] < 0.05 and diff["before"]["ndvi"] > 0.03:
1114
+ bld += 0.12
1115
+ if diff["hull_ratio_after"] > 0.55:
1116
+ bld += 0.10
1117
+ if 1.0 <= aspect_ratio <= 4.0:
1118
+ bld += 0.08
1119
+ if area > 1000:
1120
+ bld += 0.05
1121
+ else:
1122
+ if feat_a["orientation_entropy"] < 2.5:
1123
+ bld += 0.18
1124
+ if feat_a["color_homogeneity"] < 28:
1125
+ bld += 0.15
1126
+ if 1.0 <= aspect_ratio <= 4.0:
1127
+ bld += 0.12
1128
+ if 0.3 <= compactness <= 0.9:
1129
+ bld += 0.10
1130
+ if feat_a["edge_density"] > 30:
1131
+ bld += 0.12
1132
+ if feat_a["glcm_contrast"] > 400:
1133
+ bld += 0.10
1134
+ if feat_a["saturation"] < 90:
1135
+ bld += 0.10
1136
+ if 40 <= feat_a["brightness"] <= 90:
1137
+ bld += 0.08
1138
+ if area > 1000:
1139
+ bld += 0.05
1140
  scores["New Construction/Building"] = bld
1141
 
1142
  # ---- Demolition/Clearing ----
1143
  demo = 0.0
1144
+ if diff:
1145
+ if diff["delta_edge_density"] < -15:
1146
+ demo += 0.22
1147
+ if diff["delta_lines"] < -5:
1148
+ demo += 0.18
1149
+ if diff["delta_corners"] < -8:
1150
+ demo += 0.15
1151
+ if diff["delta_texture_std"] > 8:
1152
+ demo += 0.12
1153
+ if diff["delta_brightness"] > 10:
1154
+ demo += 0.12
1155
+ if diff["after"]["ndvi"] > 0.03 and diff["before"]["ndvi"] < 0.02:
1156
+ demo += 0.08
1157
+ if area > 800:
1158
+ demo += 0.05
1159
+ else:
1160
+ if feat_a["texture_std"] > 30:
1161
+ demo += 0.18
1162
+ if feat_a["orientation_entropy"] > 2.8:
1163
+ demo += 0.15
1164
+ if feat_a["color_homogeneity"] > 25:
1165
+ demo += 0.15
1166
+ if feat_a["brightness"] > 60:
1167
+ demo += 0.10
1168
+ if feat_a["ndvi"] < 0.05:
1169
+ demo += 0.12
1170
+ if feat_a["saturation"] < 70:
1171
+ demo += 0.10
1172
+ if area > 800:
1173
+ demo += 0.05
1174
  scores["Demolition/Clearing"] = demo
1175
 
1176
  # ---- Road/Pavement Change ----
1177
  road = 0.0
1178
  if aspect_ratio > 2.5:
1179
  road += 0.22
1180
+ if feat_a["color_homogeneity"] < 22:
1181
  road += 0.18
1182
+ if feat_a["texture_std"] < 32:
1183
  road += 0.15
1184
+ if feat_a["saturation"] < 65:
1185
  road += 0.12
1186
+ if feat_a["orientation_entropy"] < 2.0:
1187
  road += 0.15
1188
+ if 35 <= feat_a["brightness"] <= 75:
1189
  road += 0.10
1190
  if compactness < 0.3:
1191
  road += 0.05
 
1195
 
1196
  # ---- Bare Land/Soil Change ----
1197
  soil = 0.0
1198
+ if feat_a["red_ratio"] > 0.34 and feat_a["green_ratio"] < 0.36:
1199
  soil += 0.20
1200
+ if 8 <= feat_a["hue"] <= 38:
1201
  soil += 0.18
1202
+ if feat_a["ndvi"] < 0.05:
1203
  soil += 0.18
1204
+ if feat_a["texture_std"] < 35:
1205
  soil += 0.12
1206
+ if feat_a["lbp_variance"] < 0.04:
1207
  soil += 0.12
1208
+ if 40 <= feat_a["saturation"] <= 130:
1209
  soil += 0.10
1210
+ if 45 <= feat_a["brightness"] <= 82:
1211
  soil += 0.10
1212
  scores["Bare Land/Soil Change"] = soil
1213
 
 
 
1214
  best = max(scores, key=scores.get)
1215
  conf = scores[best]
1216
 
 
1219
  return best, min(conf, 1.0)
1220
 
1221
 
1222
+ def classify_with_ensemble(image_region, bbox, before_region=None):
1223
  """Ensemble: classify full region + sub-regions, vote with confidence weighting."""
1224
  x, y, w, h = bbox
1225
+ sub_boxes = [(x, y, w, h)]
1226
 
1227
  if w > 20 and h > 20:
1228
  hw, hh = w // 2, h // 2
 
1238
  confidences = []
1239
  transient_count = 0
1240
  for sb in sub_boxes:
1241
+ obj_type, conf = classify_object_type(image_region, sb,
1242
+ before_region=before_region)
1243
  if obj_type is None:
1244
  transient_count += 1
1245
  continue
 
1247
  classifications.append(obj_type)
1248
  confidences.append(conf)
1249
 
 
1250
  if transient_count > len(sub_boxes) // 2:
1251
  return None, 0.0
1252
 
1253
  if not classifications:
1254
+ return classify_object_type(image_region, (x, y, w, h),
1255
+ before_region=before_region)
1256
 
 
1257
  weighted = {}
1258
  counts = Counter(classifications)
1259
  for ot, c in zip(classifications, confidences):
 
1926
  cx, cy = centroids[i]
1927
 
1928
  if use_ensemble and raw_area > 500:
1929
+ object_type, confidence = classify_with_ensemble(
1930
+ image, (x, y, w, h), before_region=before_img)
1931
  else:
1932
+ object_type, confidence = classify_object_type(
1933
+ image, (x, y, w, h), before_region=before_img)
1934
 
1935
  if object_type is None:
1936
  # Do not silently drop large coherent regions; keep them as generic
app/model_inference.py CHANGED
@@ -1,16 +1,14 @@
1
  """
2
- Siamese U-Net inference for satellite change detection.
3
 
4
- Loads a TorchScript model exported from the training notebook and runs
5
  tile-based inference on arbitrary-size image pairs, producing a binary
6
  change mask compatible with the rest of the detection pipeline.
7
 
8
- Set CHANGE_MODEL_PATH env var to the .pt file location.
9
- Falls back to the rule-based AI fusion when no model is available.
10
  """
11
  import logging
12
  import os
13
- from pathlib import Path
14
 
15
  import cv2
16
  import numpy as np
@@ -18,72 +16,72 @@ import numpy as np
18
  logger = logging.getLogger(__name__)
19
 
20
  _MODEL = None
21
- _MODEL_PATH = os.environ.get("CHANGE_MODEL_PATH", "data/siamese_unet.pt")
22
- _TILE_SIZE = 256
23
- _MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
24
- _STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
 
25
 
26
 
27
- def _get_torch():
28
- """Lazy import torch — only when model exists."""
29
  try:
30
  import torch
31
- return torch
 
32
  except ImportError:
33
- return None
34
 
35
 
36
  def is_model_available():
37
- """Check if a trained model file exists and torch is installed."""
38
- return Path(_MODEL_PATH).is_file() and _get_torch() is not None
 
 
 
 
 
39
 
40
 
41
  def _load_model():
42
- global _MODEL
43
  if _MODEL is not None:
44
- return _MODEL
45
- torch = _get_torch()
 
46
  if torch is None:
47
- raise RuntimeError("PyTorch is not installed")
48
- path = Path(_MODEL_PATH)
49
- if not path.is_file():
50
- raise FileNotFoundError(f"Model not found at {path}")
51
- device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
52
- _MODEL = torch.jit.load(str(path), map_location=device)
53
- _MODEL.eval()
54
- logger.info("Loaded Siamese U-Net from %s on %s", path, device)
55
- return _MODEL
56
 
 
57
 
58
- def _preprocess_tile(tile):
59
- """Normalize a (H, W, 3) uint8 RGB tile to (1, 3, H, W) float tensor."""
60
- torch = _get_torch()
61
- img = tile.astype(np.float32) / 255.0
62
- img = (img - _MEAN) / _STD
63
- tensor = torch.from_numpy(img.transpose(2, 0, 1)).unsqueeze(0)
64
- return tensor
 
65
 
66
 
67
  def predict_change_mask(img1, img2, threshold=0.5):
68
  """
69
- Run Siamese U-Net inference on two RGB numpy arrays (H, W, 3).
70
  Images are split into overlapping tiles, predicted individually,
71
  and stitched back into a full-resolution binary mask.
72
 
73
- Returns a uint8 mask (0 or 255) at the input resolution.
74
  """
75
- torch = _get_torch()
76
- model = _load_model()
77
- device = next(model.parameters()).device
78
 
79
  if img1.shape != img2.shape:
80
  img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
81
 
82
  h, w = img1.shape[:2]
83
  tile = _TILE_SIZE
84
- stride = tile * 3 // 4 # 75% overlap for smoother stitching
85
 
86
- # Pad to make dimensions divisible by tile size
87
  pad_h = (tile - h % tile) % tile
88
  pad_w = (tile - w % tile) % tile
89
  if pad_h or pad_w:
@@ -97,17 +95,32 @@ def predict_change_mask(img1, img2, threshold=0.5):
97
  with torch.no_grad():
98
  for y0 in range(0, ph - tile + 1, stride):
99
  for x0 in range(0, pw - tile + 1, stride):
100
- t1 = _preprocess_tile(img1[y0:y0+tile, x0:x0+tile])
101
- t2 = _preprocess_tile(img2[y0:y0+tile, x0:x0+tile])
102
- logits = model(t1.to(device), t2.to(device))
103
- prob = torch.sigmoid(logits).squeeze().cpu().numpy()
104
- score_sum[y0:y0+tile, x0:x0+tile] += prob
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
105
  count[y0:y0+tile, x0:x0+tile] += 1.0
106
 
107
  count = np.maximum(count, 1.0)
108
  avg_score = score_sum / count
109
-
110
- # Crop back to original size
111
  avg_score = avg_score[:h, :w]
112
 
113
  mask = (avg_score >= threshold).astype(np.uint8) * 255
 
1
  """
2
+ AdaptFormer inference for satellite change detection.
3
 
4
+ Downloads a pre-trained AdaptFormer model from HuggingFace Hub and runs
5
  tile-based inference on arbitrary-size image pairs, producing a binary
6
  change mask compatible with the rest of the detection pipeline.
7
 
8
+ Falls back gracefully when torch/transformers are not installed.
 
9
  """
10
  import logging
11
  import os
 
12
 
13
  import cv2
14
  import numpy as np
 
16
  logger = logging.getLogger(__name__)
17
 
18
  _MODEL = None
19
+ _PROCESSOR = None
20
+ _DEVICE = None
21
+ _MODEL_ID = "deepang/adaptformer-LEVIR-CD"
22
+ _TILE_SIZE = 512
23
+ _AVAILABLE = None
24
 
25
 
26
+ def _try_import():
 
27
  try:
28
  import torch
29
+ from transformers import AutoImageProcessor, AutoModel
30
+ return torch, AutoImageProcessor, AutoModel
31
  except ImportError:
32
+ return None, None, None
33
 
34
 
35
  def is_model_available():
36
+ """Check if torch and transformers are installed."""
37
+ global _AVAILABLE
38
+ if _AVAILABLE is not None:
39
+ return _AVAILABLE
40
+ torch, _, _ = _try_import()
41
+ _AVAILABLE = torch is not None
42
+ return _AVAILABLE
43
 
44
 
45
  def _load_model():
46
+ global _MODEL, _PROCESSOR, _DEVICE
47
  if _MODEL is not None:
48
+ return _MODEL, _PROCESSOR
49
+
50
+ torch, AutoImageProcessor, AutoModel = _try_import()
51
  if torch is None:
52
+ raise RuntimeError("PyTorch/transformers not installed")
 
 
 
 
 
 
 
 
53
 
54
+ _DEVICE = torch.device("cuda" if torch.cuda.is_available() else "cpu")
55
 
56
+ cache_dir = os.environ.get("HF_HOME", None)
57
+ logger.info("Loading AdaptFormer from %s ...", _MODEL_ID)
58
+ _PROCESSOR = AutoImageProcessor.from_pretrained(_MODEL_ID, cache_dir=cache_dir)
59
+ _MODEL = AutoModel.from_pretrained(_MODEL_ID, cache_dir=cache_dir)
60
+ _MODEL.to(_DEVICE)
61
+ _MODEL.eval()
62
+ logger.info("AdaptFormer loaded on %s", _DEVICE)
63
+ return _MODEL, _PROCESSOR
64
 
65
 
66
  def predict_change_mask(img1, img2, threshold=0.5):
67
  """
68
+ Run AdaptFormer inference on two RGB numpy arrays (H, W, 3).
69
  Images are split into overlapping tiles, predicted individually,
70
  and stitched back into a full-resolution binary mask.
71
 
72
+ Returns (uint8 mask [0 or 255], float32 score map [0-1]).
73
  """
74
+ torch, _, _ = _try_import()
75
+ model, processor = _load_model()
76
+ from PIL import Image as PILImage
77
 
78
  if img1.shape != img2.shape:
79
  img2 = cv2.resize(img2, (img1.shape[1], img1.shape[0]))
80
 
81
  h, w = img1.shape[:2]
82
  tile = _TILE_SIZE
83
+ stride = tile * 3 // 4
84
 
 
85
  pad_h = (tile - h % tile) % tile
86
  pad_w = (tile - w % tile) % tile
87
  if pad_h or pad_w:
 
95
  with torch.no_grad():
96
  for y0 in range(0, ph - tile + 1, stride):
97
  for x0 in range(0, pw - tile + 1, stride):
98
+ t1 = img1[y0:y0+tile, x0:x0+tile]
99
+ t2 = img2[y0:y0+tile, x0:x0+tile]
100
+
101
+ pil1 = PILImage.fromarray(t1)
102
+ pil2 = PILImage.fromarray(t2)
103
+
104
+ inputs = processor(images=(pil1, pil2), return_tensors="pt")
105
+ inputs = {k: v.to(_DEVICE) for k, v in inputs.items()}
106
+
107
+ outputs = model(**inputs)
108
+ logits = outputs.logits
109
+ probs = torch.softmax(logits, dim=1)
110
+
111
+ # Class 1 = change
112
+ prob_map = probs[0, 1].cpu().numpy()
113
+
114
+ out_h, out_w = prob_map.shape
115
+ if out_h != tile or out_w != tile:
116
+ prob_map = cv2.resize(prob_map, (tile, tile),
117
+ interpolation=cv2.INTER_LINEAR)
118
+
119
+ score_sum[y0:y0+tile, x0:x0+tile] += prob_map
120
  count[y0:y0+tile, x0:x0+tile] += 1.0
121
 
122
  count = np.maximum(count, 1.0)
123
  avg_score = score_sum / count
 
 
124
  avg_score = avg_score[:h, :w]
125
 
126
  mask = (avg_score >= threshold).astype(np.uint8) * 255
requirements.txt CHANGED
@@ -1,3 +1,7 @@
 
 
 
 
1
  fastapi>=0.104.0
2
  uvicorn[standard]>=0.24.0
3
  python-multipart>=0.0.6
 
1
+ --extra-index-url https://download.pytorch.org/whl/cpu
2
+ torch
3
+ torchvision
4
+ transformers>=4.35.0
5
  fastapi>=0.104.0
6
  uvicorn[standard]>=0.24.0
7
  python-multipart>=0.0.6
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=21" />
8
  </head>
9
  <body>
10
  <div class="app">
@@ -360,6 +360,6 @@
360
  </div>
361
  </div>
362
 
363
- <script src="/static/js/app.js?v=36"></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=22" />
8
  </head>
9
  <body>
10
  <div class="app">
 
360
  </div>
361
  </div>
362
 
363
+ <script src="/static/js/app.js?v=37"></script>
364
  </body>
365
  </html>
train_change_detection_model.ipynb DELETED
@@ -1,633 +0,0 @@
1
- {
2
- "cells": [
3
- {
4
- "cell_type": "markdown",
5
- "metadata": {},
6
- "source": [
7
- "# Satellite Change Detection — Siamese U-Net Training\n",
8
- "\n",
9
- "This notebook trains a **Siamese U-Net** on the **LEVIR-CD+** dataset for pixel-level\n",
10
- "satellite image change detection. The exported model plugs directly into the\n",
11
- "AI Change Detection web app.\n",
12
- "\n",
13
- "**Optimized for CPU** — uses a lightweight MobileNetV2 encoder and 15 epochs.\n",
14
- "Training takes ~3-4 hours on a Colab CPU runtime."
15
- ]
16
- },
17
- {
18
- "cell_type": "markdown",
19
- "metadata": {},
20
- "source": [
21
- "## 1. Install Dependencies"
22
- ]
23
- },
24
- {
25
- "cell_type": "code",
26
- "metadata": {},
27
- "source": [
28
- "!pip install -q torch torchvision segmentation-models-pytorch albumentations datasets tqdm matplotlib"
29
- ],
30
- "execution_count": null,
31
- "outputs": []
32
- },
33
- {
34
- "cell_type": "markdown",
35
- "metadata": {},
36
- "source": [
37
- "## 2. Download & Prepare LEVIR-CD+ Dataset\n",
38
- "\n",
39
- "LEVIR-CD+ contains 985 pairs of 1024×1024 Google Earth images with pixel-level\n",
40
- "building change annotations. We download it from **Hugging Face** (reliable CDN,\n",
41
- "no Google Drive rate limits), then cut each image into 256×256 patches."
42
- ]
43
- },
44
- {
45
- "cell_type": "code",
46
- "metadata": {},
47
- "source": [
48
- "import os\n",
49
- "import numpy as np\n",
50
- "from PIL import Image\n",
51
- "from datasets import load_dataset\n",
52
- "\n",
53
- "DATA_ROOT = \"./levir_cd_256\"\n",
54
- "PATCH_SIZE = 256\n",
55
- "\n",
56
- "def save_patches(split_data, out_dir, start_idx=0):\n",
57
- " \"\"\"Cut 1024×1024 images into 256×256 patches and save to disk.\"\"\"\n",
58
- " os.makedirs(os.path.join(out_dir, \"A\"), exist_ok=True)\n",
59
- " os.makedirs(os.path.join(out_dir, \"B\"), exist_ok=True)\n",
60
- " os.makedirs(os.path.join(out_dir, \"label\"), exist_ok=True)\n",
61
- " patch_id = start_idx\n",
62
- " for row in split_data:\n",
63
- " img_a = np.array(row[\"image1\"].convert(\"RGB\"))\n",
64
- " img_b = np.array(row[\"image2\"].convert(\"RGB\"))\n",
65
- " mask = np.array(row[\"mask\"].convert(\"L\"))\n",
66
- " h, w = img_a.shape[:2]\n",
67
- " for y in range(0, h - PATCH_SIZE + 1, PATCH_SIZE):\n",
68
- " for x in range(0, w - PATCH_SIZE + 1, PATCH_SIZE):\n",
69
- " pa = img_a[y:y+PATCH_SIZE, x:x+PATCH_SIZE]\n",
70
- " pb = img_b[y:y+PATCH_SIZE, x:x+PATCH_SIZE]\n",
71
- " pm = mask[y:y+PATCH_SIZE, x:x+PATCH_SIZE]\n",
72
- " name = f\"{patch_id:05d}.png\"\n",
73
- " Image.fromarray(pa).save(os.path.join(out_dir, \"A\", name))\n",
74
- " Image.fromarray(pb).save(os.path.join(out_dir, \"B\", name))\n",
75
- " Image.fromarray(pm).save(os.path.join(out_dir, \"label\", name))\n",
76
- " patch_id += 1\n",
77
- " return patch_id\n",
78
- "\n",
79
- "if not os.path.isdir(DATA_ROOT):\n",
80
- " print(\"Downloading LEVIR-CD+ from Hugging Face (~3.8 GB)...\")\n",
81
- " ds = load_dataset(\"blanchon/LEVIR_CDPlus\")\n",
82
- "\n",
83
- " # The dataset has 'train' and 'test' splits\n",
84
- " train_data = ds[\"train\"]\n",
85
- " test_data = ds[\"test\"]\n",
86
- "\n",
87
- " # Use last 10% of train as validation\n",
88
- " n_train = len(train_data)\n",
89
- " n_val = max(1, int(n_train * 0.1))\n",
90
- " val_indices = list(range(n_train - n_val, n_train))\n",
91
- " train_indices = list(range(0, n_train - n_val))\n",
92
- "\n",
93
- " print(f\"Total train images: {n_train}, using {len(train_indices)} train + {len(val_indices)} val\")\n",
94
- " print(f\"Test images: {len(test_data)}\")\n",
95
- "\n",
96
- " print(\"Cutting into 256×256 patches (this takes a few minutes)...\")\n",
97
- " n = save_patches(train_data.select(train_indices), os.path.join(DATA_ROOT, \"train\"))\n",
98
- " print(f\" Train patches: {n}\")\n",
99
- " n = save_patches(train_data.select(val_indices), os.path.join(DATA_ROOT, \"val\"))\n",
100
- " print(f\" Val patches: {n}\")\n",
101
- " n = save_patches(test_data, os.path.join(DATA_ROOT, \"test\"))\n",
102
- " print(f\" Test patches: {n}\")\n",
103
- " print(\"Done! Dataset at:\", DATA_ROOT)\n",
104
- "else:\n",
105
- " print(\"Dataset already present at\", DATA_ROOT)"
106
- ],
107
- "execution_count": null,
108
- "outputs": []
109
- },
110
- {
111
- "cell_type": "code",
112
- "metadata": {},
113
- "source": [
114
- "# Verify structure — adjust paths if your zip extracts differently\n",
115
- "for split in [\"train\", \"val\", \"test\"]:\n",
116
- " for sub in [\"A\", \"B\", \"label\"]:\n",
117
- " p = os.path.join(DATA_ROOT, split, sub)\n",
118
- " if os.path.isdir(p):\n",
119
- " n = len(os.listdir(p))\n",
120
- " print(f\"{split}/{sub}: {n} files\")\n",
121
- " else:\n",
122
- " print(f\"WARNING: {p} not found — check extracted folder name\")"
123
- ],
124
- "execution_count": null,
125
- "outputs": []
126
- },
127
- {
128
- "cell_type": "markdown",
129
- "metadata": {},
130
- "source": [
131
- "## 3. Dataset & DataLoader"
132
- ]
133
- },
134
- {
135
- "cell_type": "code",
136
- "metadata": {},
137
- "source": [
138
- "from torch.utils.data import Dataset, DataLoader\n",
139
- "import albumentations as A\n",
140
- "from albumentations.pytorch import ToTensorV2\n",
141
- "\n",
142
- "\n",
143
- "class LEVIRCDDataset(Dataset):\n",
144
- " \"\"\"LEVIR-CD patch dataset: before (A), after (B), binary label.\"\"\"\n",
145
- "\n",
146
- " def __init__(self, root, split=\"train\", transform=None):\n",
147
- " self.dir_a = os.path.join(root, split, \"A\")\n",
148
- " self.dir_b = os.path.join(root, split, \"B\")\n",
149
- " self.dir_label = os.path.join(root, split, \"label\")\n",
150
- " self.fnames = sorted(os.listdir(self.dir_a))\n",
151
- " self.transform = transform\n",
152
- "\n",
153
- " def __len__(self):\n",
154
- " return len(self.fnames)\n",
155
- "\n",
156
- " def __getitem__(self, idx):\n",
157
- " name = self.fnames[idx]\n",
158
- " img_a = np.array(Image.open(os.path.join(self.dir_a, name)).convert(\"RGB\"))\n",
159
- " img_b = np.array(Image.open(os.path.join(self.dir_b, name)).convert(\"RGB\"))\n",
160
- " label = np.array(Image.open(os.path.join(self.dir_label, name)).convert(\"L\"))\n",
161
- " label = (label > 127).astype(np.float32)\n",
162
- "\n",
163
- " if self.transform:\n",
164
- " aug = self.transform(\n",
165
- " image=img_a,\n",
166
- " image_b=img_b,\n",
167
- " mask=label,\n",
168
- " )\n",
169
- " img_a = aug[\"image\"] # (3, H, W) tensor\n",
170
- " img_b = aug[\"image_b\"] # (3, H, W) tensor\n",
171
- " label = aug[\"mask\"].unsqueeze(0) # (1, H, W)\n",
172
- " return img_a, img_b, label\n",
173
- "\n",
174
- "\n",
175
- "train_transform = A.Compose(\n",
176
- " [\n",
177
- " A.HorizontalFlip(p=0.5),\n",
178
- " A.VerticalFlip(p=0.5),\n",
179
- " A.RandomRotate90(p=0.5),\n",
180
- " A.RandomBrightnessContrast(p=0.3, brightness_limit=0.15, contrast_limit=0.15),\n",
181
- " A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n",
182
- " ToTensorV2(),\n",
183
- " ],\n",
184
- " additional_targets={\"image_b\": \"image\"},\n",
185
- ")\n",
186
- "\n",
187
- "val_transform = A.Compose(\n",
188
- " [\n",
189
- " A.Normalize(mean=(0.485, 0.456, 0.406), std=(0.229, 0.224, 0.225)),\n",
190
- " ToTensorV2(),\n",
191
- " ],\n",
192
- " additional_targets={\"image_b\": \"image\"},\n",
193
- ")\n",
194
- "\n",
195
- "train_ds = LEVIRCDDataset(DATA_ROOT, \"train\", train_transform)\n",
196
- "val_ds = LEVIRCDDataset(DATA_ROOT, \"val\", val_transform)\n",
197
- "test_ds = LEVIRCDDataset(DATA_ROOT, \"test\", val_transform)\n",
198
- "\n",
199
- "BATCH = 4 # smaller batch for CPU\n",
200
- "train_dl = DataLoader(train_ds, batch_size=BATCH, shuffle=True, num_workers=0, pin_memory=False)\n",
201
- "val_dl = DataLoader(val_ds, batch_size=BATCH, shuffle=False, num_workers=0, pin_memory=False)\n",
202
- "test_dl = DataLoader(test_ds, batch_size=BATCH, shuffle=False, num_workers=0, pin_memory=False)\n",
203
- "\n",
204
- "print(f\"Train: {len(train_ds)}, Val: {len(val_ds)}, Test: {len(test_ds)}\")"
205
- ],
206
- "execution_count": null,
207
- "outputs": []
208
- },
209
- {
210
- "cell_type": "markdown",
211
- "metadata": {},
212
- "source": [
213
- "## 4. Siamese U-Net Model\n",
214
- "\n",
215
- "Architecture:\n",
216
- "- **Shared encoder** (MobileNetV2, ImageNet pretrained) — lightweight and fast on CPU\n",
217
- "- Feature maps from both branches are **concatenated** at each decoder level\n",
218
- "- Standard U-Net decoder produces a binary change mask"
219
- ]
220
- },
221
- {
222
- "cell_type": "code",
223
- "metadata": {},
224
- "source": [
225
- "import torch\n",
226
- "import torch.nn as nn\n",
227
- "import segmentation_models_pytorch as smp\n",
228
- "\n",
229
- "\n",
230
- "ENCODER_NAME = \"mobilenet_v2\" # lightweight encoder for CPU training\n",
231
- "\n",
232
- "\n",
233
- "class SiameseUNet(nn.Module):\n",
234
- " \"\"\"\n",
235
- " Siamese U-Net for change detection.\n",
236
- " Shared encoder extracts features from both images;\n",
237
- " concatenated features are decoded into a binary change mask.\n",
238
- " \"\"\"\n",
239
- "\n",
240
- " def __init__(self, encoder_name=ENCODER_NAME, pretrained=True):\n",
241
- " super().__init__()\n",
242
- " aux = smp.Unet(\n",
243
- " encoder_name=encoder_name,\n",
244
- " encoder_weights=\"imagenet\" if pretrained else None,\n",
245
- " in_channels=3,\n",
246
- " classes=1,\n",
247
- " )\n",
248
- " self.encoder = aux.encoder\n",
249
- "\n",
250
- " encoder_channels = self.encoder.out_channels\n",
251
- " doubled = tuple(c * 2 for c in encoder_channels)\n",
252
- "\n",
253
- " self.decoder = smp.decoders.unet.decoder.UnetDecoder(\n",
254
- " encoder_channels=doubled,\n",
255
- " decoder_channels=(256, 128, 64, 32, 16),\n",
256
- " n_blocks=5,\n",
257
- " use_batchnorm=True,\n",
258
- " attention_type=None,\n",
259
- " )\n",
260
- "\n",
261
- " self.head = nn.Conv2d(16, 1, kernel_size=1)\n",
262
- "\n",
263
- " def forward(self, img_a, img_b):\n",
264
- " feats_a = self.encoder(img_a)\n",
265
- " feats_b = self.encoder(img_b)\n",
266
- " feats_cat = [torch.cat([fa, fb], dim=1) for fa, fb in zip(feats_a, feats_b)]\n",
267
- " decoded = self.decoder(*feats_cat)\n",
268
- " logits = self.head(decoded)\n",
269
- " return logits\n",
270
- "\n",
271
- "\n",
272
- "device = torch.device(\"cuda\" if torch.cuda.is_available() else \"cpu\")\n",
273
- "model = SiameseUNet(encoder_name=ENCODER_NAME, pretrained=True).to(device)\n",
274
- "\n",
275
- "total_params = sum(p.numel() for p in model.parameters()) / 1e6\n",
276
- "print(f\"Model on {device}, {total_params:.1f}M parameters\")\n",
277
- "if device.type == \"cpu\":\n",
278
- " print(\"Running on CPU — training will take ~3-4 hours for 15 epochs\")"
279
- ],
280
- "execution_count": null,
281
- "outputs": []
282
- },
283
- {
284
- "cell_type": "markdown",
285
- "metadata": {},
286
- "source": [
287
- "## 5. Loss Function & Metrics\n",
288
- "\n",
289
- "Combined **BCE + Dice** loss handles class imbalance (most pixels are unchanged)."
290
- ]
291
- },
292
- {
293
- "cell_type": "code",
294
- "metadata": {},
295
- "source": [
296
- "class BCEDiceLoss(nn.Module):\n",
297
- " def __init__(self, bce_weight=0.5):\n",
298
- " super().__init__()\n",
299
- " self.bce = nn.BCEWithLogitsLoss()\n",
300
- " self.bce_weight = bce_weight\n",
301
- "\n",
302
- " def forward(self, logits, targets):\n",
303
- " bce_loss = self.bce(logits, targets)\n",
304
- " probs = torch.sigmoid(logits)\n",
305
- " smooth = 1.0\n",
306
- " intersection = (probs * targets).sum()\n",
307
- " dice = (2.0 * intersection + smooth) / (probs.sum() + targets.sum() + smooth)\n",
308
- " dice_loss = 1.0 - dice\n",
309
- " return self.bce_weight * bce_loss + (1 - self.bce_weight) * dice_loss\n",
310
- "\n",
311
- "\n",
312
- "def compute_metrics(preds, targets, threshold=0.5):\n",
313
- " \"\"\"Compute precision, recall, F1, and IoU.\"\"\"\n",
314
- " preds_bin = (preds > threshold).float()\n",
315
- " tp = (preds_bin * targets).sum().item()\n",
316
- " fp = (preds_bin * (1 - targets)).sum().item()\n",
317
- " fn = ((1 - preds_bin) * targets).sum().item()\n",
318
- " precision = tp / (tp + fp + 1e-8)\n",
319
- " recall = tp / (tp + fn + 1e-8)\n",
320
- " f1 = 2 * precision * recall / (precision + recall + 1e-8)\n",
321
- " iou = tp / (tp + fp + fn + 1e-8)\n",
322
- " return {\"precision\": precision, \"recall\": recall, \"f1\": f1, \"iou\": iou}"
323
- ],
324
- "execution_count": null,
325
- "outputs": []
326
- },
327
- {
328
- "cell_type": "markdown",
329
- "metadata": {},
330
- "source": [
331
- "## 6. Training Loop"
332
- ]
333
- },
334
- {
335
- "cell_type": "code",
336
- "metadata": {},
337
- "source": [
338
- "import time\n",
339
- "from tqdm.auto import tqdm\n",
340
- "\n",
341
- "NUM_EPOCHS = 15 # fewer epochs for CPU training\n",
342
- "LR = 3e-4 # slightly higher LR to converge faster\n",
343
- "\n",
344
- "criterion = BCEDiceLoss(bce_weight=0.5)\n",
345
- "optimizer = torch.optim.Adam(model.parameters(), lr=LR, weight_decay=1e-4)\n",
346
- "scheduler = torch.optim.lr_scheduler.CosineAnnealingLR(optimizer, T_max=NUM_EPOCHS, eta_min=1e-6)\n",
347
- "\n",
348
- "best_f1 = 0.0\n",
349
- "history = {\"train_loss\": [], \"val_loss\": [], \"val_f1\": [], \"val_iou\": []}\n",
350
- "train_start = time.time()\n",
351
- "\n",
352
- "for epoch in range(1, NUM_EPOCHS + 1):\n",
353
- " epoch_start = time.time()\n",
354
- "\n",
355
- " # --- Train ---\n",
356
- " model.train()\n",
357
- " running_loss = 0.0\n",
358
- " for img_a, img_b, label in tqdm(train_dl, desc=f\"Epoch {epoch}/{NUM_EPOCHS} [train]\", leave=False):\n",
359
- " img_a = img_a.to(device)\n",
360
- " img_b = img_b.to(device)\n",
361
- " label = label.to(device)\n",
362
- "\n",
363
- " logits = model(img_a, img_b)\n",
364
- " loss = criterion(logits, label)\n",
365
- "\n",
366
- " optimizer.zero_grad()\n",
367
- " loss.backward()\n",
368
- " torch.nn.utils.clip_grad_norm_(model.parameters(), 1.0)\n",
369
- " optimizer.step()\n",
370
- " running_loss += loss.item() * img_a.size(0)\n",
371
- "\n",
372
- " train_loss = running_loss / len(train_ds)\n",
373
- " scheduler.step()\n",
374
- "\n",
375
- " # --- Validate ---\n",
376
- " model.eval()\n",
377
- " val_loss_sum = 0.0\n",
378
- " all_preds, all_targets = [], []\n",
379
- " with torch.no_grad():\n",
380
- " for img_a, img_b, label in val_dl:\n",
381
- " img_a = img_a.to(device)\n",
382
- " img_b = img_b.to(device)\n",
383
- " label = label.to(device)\n",
384
- "\n",
385
- " logits = model(img_a, img_b)\n",
386
- " val_loss_sum += criterion(logits, label).item() * img_a.size(0)\n",
387
- " all_preds.append(torch.sigmoid(logits).cpu())\n",
388
- " all_targets.append(label.cpu())\n",
389
- "\n",
390
- " val_loss = val_loss_sum / len(val_ds)\n",
391
- " preds_cat = torch.cat(all_preds)\n",
392
- " targets_cat = torch.cat(all_targets)\n",
393
- " metrics = compute_metrics(preds_cat, targets_cat)\n",
394
- "\n",
395
- " history[\"train_loss\"].append(train_loss)\n",
396
- " history[\"val_loss\"].append(val_loss)\n",
397
- " history[\"val_f1\"].append(metrics[\"f1\"])\n",
398
- " history[\"val_iou\"].append(metrics[\"iou\"])\n",
399
- "\n",
400
- " elapsed_min = (time.time() - epoch_start) / 60\n",
401
- " total_min = (time.time() - train_start) / 60\n",
402
- " eta_min = elapsed_min * (NUM_EPOCHS - epoch)\n",
403
- "\n",
404
- " print(\n",
405
- " f\"Epoch {epoch:02d} | \"\n",
406
- " f\"train_loss={train_loss:.4f} | \"\n",
407
- " f\"val_loss={val_loss:.4f} | \"\n",
408
- " f\"F1={metrics['f1']:.4f} | \"\n",
409
- " f\"IoU={metrics['iou']:.4f} | \"\n",
410
- " f\"P={metrics['precision']:.4f} R={metrics['recall']:.4f} | \"\n",
411
- " f\"{elapsed_min:.1f}min (ETA: {eta_min:.0f}min)\"\n",
412
- " )\n",
413
- "\n",
414
- " if metrics[\"f1\"] > best_f1:\n",
415
- " best_f1 = metrics[\"f1\"]\n",
416
- " torch.save(model.state_dict(), \"best_siamese_unet.pth\")\n",
417
- " print(f\" >> Saved best model (F1={best_f1:.4f})\")\n",
418
- "\n",
419
- "total_time = (time.time() - train_start) / 60\n",
420
- "print(f\"\\nTraining complete in {total_time:.1f} minutes. Best val F1: {best_f1:.4f}\")"
421
- ],
422
- "execution_count": null,
423
- "outputs": []
424
- },
425
- {
426
- "cell_type": "markdown",
427
- "metadata": {},
428
- "source": [
429
- "## 7. Training Curves"
430
- ]
431
- },
432
- {
433
- "cell_type": "code",
434
- "metadata": {},
435
- "source": [
436
- "import matplotlib.pyplot as plt\n",
437
- "\n",
438
- "fig, axes = plt.subplots(1, 3, figsize=(15, 4))\n",
439
- "\n",
440
- "axes[0].plot(history[\"train_loss\"], label=\"Train\")\n",
441
- "axes[0].plot(history[\"val_loss\"], label=\"Val\")\n",
442
- "axes[0].set_title(\"Loss\")\n",
443
- "axes[0].legend()\n",
444
- "\n",
445
- "axes[1].plot(history[\"val_f1\"])\n",
446
- "axes[1].set_title(\"Val F1 Score\")\n",
447
- "\n",
448
- "axes[2].plot(history[\"val_iou\"])\n",
449
- "axes[2].set_title(\"Val IoU\")\n",
450
- "\n",
451
- "for ax in axes:\n",
452
- " ax.set_xlabel(\"Epoch\")\n",
453
- " ax.grid(True, alpha=0.3)\n",
454
- "\n",
455
- "plt.tight_layout()\n",
456
- "plt.show()"
457
- ],
458
- "execution_count": null,
459
- "outputs": []
460
- },
461
- {
462
- "cell_type": "markdown",
463
- "metadata": {},
464
- "source": [
465
- "## 8. Evaluate on Test Set"
466
- ]
467
- },
468
- {
469
- "cell_type": "code",
470
- "metadata": {},
471
- "source": [
472
- "# Load best checkpoint\n",
473
- "model.load_state_dict(torch.load(\"best_siamese_unet.pth\", map_location=device))\n",
474
- "model.eval()\n",
475
- "\n",
476
- "all_preds, all_targets = [], []\n",
477
- "with torch.no_grad():\n",
478
- " for img_a, img_b, label in tqdm(test_dl, desc=\"Testing\"):\n",
479
- " logits = model(img_a.to(device), img_b.to(device))\n",
480
- " all_preds.append(torch.sigmoid(logits).cpu())\n",
481
- " all_targets.append(label)\n",
482
- "\n",
483
- "preds = torch.cat(all_preds)\n",
484
- "targets = torch.cat(all_targets)\n",
485
- "test_metrics = compute_metrics(preds, targets)\n",
486
- "\n",
487
- "print(f\"\\nTest Results:\")\n",
488
- "print(f\" F1 Score: {test_metrics['f1']:.4f}\")\n",
489
- "print(f\" IoU: {test_metrics['iou']:.4f}\")\n",
490
- "print(f\" Precision: {test_metrics['precision']:.4f}\")\n",
491
- "print(f\" Recall: {test_metrics['recall']:.4f}\")"
492
- ],
493
- "execution_count": null,
494
- "outputs": []
495
- },
496
- {
497
- "cell_type": "markdown",
498
- "metadata": {},
499
- "source": [
500
- "## 9. Visualize Predictions"
501
- ]
502
- },
503
- {
504
- "cell_type": "code",
505
- "metadata": {},
506
- "source": [
507
- "MEAN = np.array([0.485, 0.456, 0.406])\n",
508
- "STD = np.array([0.229, 0.224, 0.225])\n",
509
- "\n",
510
- "def denorm(tensor):\n",
511
- " img = tensor.permute(1, 2, 0).numpy()\n",
512
- " img = img * STD + MEAN\n",
513
- " return np.clip(img, 0, 1)\n",
514
- "\n",
515
- "fig, axes = plt.subplots(4, 4, figsize=(16, 16))\n",
516
- "sample_indices = np.random.choice(len(test_ds), 4, replace=False)\n",
517
- "\n",
518
- "for row, idx in enumerate(sample_indices):\n",
519
- " img_a, img_b, label = test_ds[idx]\n",
520
- " with torch.no_grad():\n",
521
- " logit = model(img_a.unsqueeze(0).to(device), img_b.unsqueeze(0).to(device))\n",
522
- " pred = (torch.sigmoid(logit) > 0.5).squeeze().cpu().numpy()\n",
523
- "\n",
524
- " axes[row, 0].imshow(denorm(img_a))\n",
525
- " axes[row, 0].set_title(\"Before\")\n",
526
- " axes[row, 1].imshow(denorm(img_b))\n",
527
- " axes[row, 1].set_title(\"After\")\n",
528
- " axes[row, 2].imshow(label.squeeze(), cmap=\"gray\")\n",
529
- " axes[row, 2].set_title(\"Ground Truth\")\n",
530
- " axes[row, 3].imshow(pred, cmap=\"gray\")\n",
531
- " axes[row, 3].set_title(\"Prediction\")\n",
532
- "\n",
533
- "for ax in axes.flat:\n",
534
- " ax.axis(\"off\")\n",
535
- "plt.tight_layout()\n",
536
- "plt.show()"
537
- ],
538
- "execution_count": null,
539
- "outputs": []
540
- },
541
- {
542
- "cell_type": "markdown",
543
- "metadata": {},
544
- "source": [
545
- "## 10. Export Model for Deployment\n",
546
- "\n",
547
- "Export as TorchScript for the web app. Download the `.pt` file and place it in\n",
548
- "your app's `data/` folder, then set the environment variable:\n",
549
- "\n",
550
- "```\n",
551
- "CHANGE_MODEL_PATH=data/siamese_unet.pt\n",
552
- "```"
553
- ]
554
- },
555
- {
556
- "cell_type": "code",
557
- "metadata": {},
558
- "source": [
559
- "model.eval()\n",
560
- "model_cpu = model.cpu()\n",
561
- "\n",
562
- "# Trace with example inputs\n",
563
- "example_a = torch.randn(1, 3, 256, 256)\n",
564
- "example_b = torch.randn(1, 3, 256, 256)\n",
565
- "traced = torch.jit.trace(model_cpu, (example_a, example_b))\n",
566
- "\n",
567
- "export_path = \"siamese_unet.pt\"\n",
568
- "traced.save(export_path)\n",
569
- "size_mb = os.path.getsize(export_path) / 1e6\n",
570
- "print(f\"Exported TorchScript model: {export_path} ({size_mb:.1f} MB)\")\n",
571
- "print(\"\\nDownload this file and place it in your app's data/ directory.\")\n",
572
- "print('Then set: CHANGE_MODEL_PATH=data/siamese_unet.pt')"
573
- ],
574
- "execution_count": null,
575
- "outputs": []
576
- },
577
- {
578
- "cell_type": "code",
579
- "metadata": {},
580
- "source": [
581
- "# Quick sanity check: verify exported model produces same output\n",
582
- "loaded = torch.jit.load(export_path)\n",
583
- "with torch.no_grad():\n",
584
- " out_orig = model_cpu(example_a, example_b)\n",
585
- " out_loaded = loaded(example_a, example_b)\n",
586
- " diff = (out_orig - out_loaded).abs().max().item()\n",
587
- " print(f\"Max diff between original and exported: {diff:.8f}\")\n",
588
- " assert diff < 1e-5, \"Export verification failed!\"\n",
589
- " print(\"Export verified successfully.\")"
590
- ],
591
- "execution_count": null,
592
- "outputs": []
593
- },
594
- {
595
- "cell_type": "markdown",
596
- "metadata": {},
597
- "source": [
598
- "## 11. Download from Colab\n",
599
- "\n",
600
- "Run this cell to trigger a browser download of the model file."
601
- ]
602
- },
603
- {
604
- "cell_type": "code",
605
- "metadata": {},
606
- "source": [
607
- "try:\n",
608
- " from google.colab import files\n",
609
- " files.download(\"siamese_unet.pt\")\n",
610
- " files.download(\"best_siamese_unet.pth\")\n",
611
- "except ImportError:\n",
612
- " print(\"Not running in Colab. Files saved locally:\")\n",
613
- " print(f\" - {export_path}\")\n",
614
- " print(f\" - best_siamese_unet.pth\")"
615
- ],
616
- "execution_count": null,
617
- "outputs": []
618
- }
619
- ],
620
- "metadata": {
621
- "kernelspec": {
622
- "display_name": "Python 3",
623
- "language": "python",
624
- "name": "python3"
625
- },
626
- "language_info": {
627
- "name": "python",
628
- "version": "3.11.0"
629
- }
630
- },
631
- "nbformat": 4,
632
- "nbformat_minor": 4
633
- }