coderuday21 commited on
Commit
94cbea0
·
1 Parent(s): ce1e651

Add 3D building analysis: shadow-based height/stories estimation and construction stage classification

Browse files
Files changed (4) hide show
  1. app/detection_engine.py +275 -12
  2. app/main.py +3 -0
  3. static/js/app.js +6 -0
  4. templates/index.html +4 -1
app/detection_engine.py CHANGED
@@ -439,14 +439,35 @@ def visualize_changes(img1, img2, change_mask, regions=None):
439
  for c in range(3):
440
  overlay[:, :, c] = overlay[:, :, c] * (1 - mask_float * alpha) + red_layer[:, :, c] * mask_float * alpha
441
 
442
- # Draw thin white outlines around each region for clarity
443
  if regions:
444
- contour_mask = np.zeros(change_mask.shape[:2], dtype=np.uint8)
445
  for r in regions:
446
  x, y, w, h = r["bbox"]
447
- cv2.rectangle(contour_mask, (x, y), (x + w, y + h), 255, 1)
448
- outline = contour_mask > 0
449
- overlay[outline] = [255, 255, 255]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
450
 
451
  return np.clip(overlay, 0, 255).astype(np.uint8)
452
 
@@ -760,13 +781,239 @@ def classify_with_ensemble(image_region, bbox, num_sub=4):
760
 
761
 
762
  # ---------------------------------------------------------------------------
763
- # 11. Region analysis
764
  # ---------------------------------------------------------------------------
765
 
766
- def analyze_change_regions(change_mask, image, min_area=200, use_ensemble=True):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
767
  """
768
  Find connected change regions, classify as ground-level changes only.
769
  Transient objects (people, cars, animals) are filtered out.
 
770
  """
771
  num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(change_mask, connectivity=8)
772
  change_regions = []
@@ -788,19 +1035,34 @@ def analyze_change_regions(change_mask, image, min_area=200, use_ensemble=True):
788
  else:
789
  object_type, confidence = classify_object_type(image, (x, y, w, h))
790
 
791
- # None means transient / irrelevant → skip
792
  if object_type is None:
793
  continue
794
 
795
  region_id += 1
796
- change_regions.append({
797
  "id": region_id,
798
  "area": area,
799
  "bbox": (x, y, w, h),
800
  "center": (int(cx), int(cy)),
801
  "object_type": object_type,
802
  "confidence": confidence,
803
- })
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
804
 
805
  change_regions.sort(key=lambda r: r["area"], reverse=True)
806
  return change_regions
@@ -830,9 +1092,10 @@ def run_detection(before_pil, after_pil, method="AI-Based Deep Learning",
830
  else:
831
  change_mask = hybrid_method(before_array, after_array)
832
 
833
- change_regions = analyze_change_regions(change_mask, after_array, min_area=200)
 
 
834
 
835
- # Color-coded visualization using region classifications
836
  result_image = visualize_changes(before_array, after_array, change_mask, regions=change_regions)
837
 
838
  total_pixels = int(change_mask.shape[0] * change_mask.shape[1])
 
439
  for c in range(3):
440
  overlay[:, :, c] = overlay[:, :, c] * (1 - mask_float * alpha) + red_layer[:, :, c] * mask_float * alpha
441
 
442
+ # Draw outlines and labels for each region
443
  if regions:
444
+ overlay_uint8 = np.clip(overlay, 0, 255).astype(np.uint8)
445
  for r in regions:
446
  x, y, w, h = r["bbox"]
447
+ cv2.rectangle(overlay_uint8, (x, y), (x + w, y + h), (255, 255, 255), 1)
448
+
449
+ # Annotate building regions with 3D info
450
+ stories = r.get("estimated_stories")
451
+ stage = r.get("construction_stage")
452
+ if stories is not None or stage is not None:
453
+ parts = []
454
+ if stories is not None:
455
+ parts.append(f"{stories}F")
456
+ if stage and stage != "Unknown":
457
+ parts.append(stage)
458
+ label = " | ".join(parts)
459
+ font = cv2.FONT_HERSHEY_SIMPLEX
460
+ font_scale = max(0.35, min(0.55, w / 200))
461
+ thickness = 1
462
+ (tw, th), _ = cv2.getTextSize(label, font, font_scale, thickness)
463
+ lx = x
464
+ ly = max(th + 4, y - 6)
465
+ # Background rectangle for readability
466
+ cv2.rectangle(overlay_uint8, (lx, ly - th - 4), (lx + tw + 6, ly + 2),
467
+ (0, 0, 0), cv2.FILLED)
468
+ cv2.putText(overlay_uint8, label, (lx + 3, ly - 2), font,
469
+ font_scale, (255, 255, 255), thickness, cv2.LINE_AA)
470
+ return overlay_uint8
471
 
472
  return np.clip(overlay, 0, 255).astype(np.uint8)
473
 
 
781
 
782
 
783
  # ---------------------------------------------------------------------------
784
+ # 11. 3D Building Analysis — height estimation + construction stage
785
  # ---------------------------------------------------------------------------
786
 
787
+ _BUILDING_TYPES = {"New Construction/Building", "Demolition/Clearing"}
788
+ _STORY_HEIGHT_M = 3.0 # assumed metres per story
789
+
790
+
791
+ def _detect_shadow_region(before_gray, after_gray, bbox, expand=0.6):
792
+ """
793
+ Find new shadow pixels adjacent to a building bbox.
794
+ Returns a binary mask of likely shadow pixels in the expanded bbox area.
795
+ """
796
+ x, y, w, h = bbox
797
+ img_h, img_w = after_gray.shape[:2]
798
+
799
+ # Expand bbox to capture shadows cast beside the building
800
+ ex = int(w * expand)
801
+ ey = int(h * expand)
802
+ x1 = max(0, x - ex)
803
+ y1 = max(0, y - ey)
804
+ x2 = min(img_w, x + w + ex)
805
+ y2 = min(img_h, y + h + ey)
806
+
807
+ before_crop = before_gray[y1:y2, x1:x2].astype(np.float32)
808
+ after_crop = after_gray[y1:y2, x1:x2].astype(np.float32)
809
+
810
+ if before_crop.size == 0 or after_crop.size == 0:
811
+ return None, 0
812
+
813
+ # New shadow = pixels that got significantly darker in the after image
814
+ darkening = before_crop - after_crop
815
+ dark_thresh = max(25, np.std(darkening) * 1.5)
816
+ shadow_mask = (darkening > dark_thresh).astype(np.uint8) * 255
817
+
818
+ # Remove shadow pixels inside the building footprint itself
819
+ bx1, by1 = x - x1, y - y1
820
+ bx2, by2 = bx1 + w, by1 + h
821
+ bx1, by1 = max(0, bx1), max(0, by1)
822
+ bx2 = min(shadow_mask.shape[1], bx2)
823
+ by2 = min(shadow_mask.shape[0], by2)
824
+ shadow_mask[by1:by2, bx1:bx2] = 0
825
+
826
+ # Clean noise
827
+ kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
828
+ shadow_mask = cv2.morphologyEx(shadow_mask, cv2.MORPH_OPEN, kernel)
829
+
830
+ shadow_pixels = np.sum(shadow_mask > 0)
831
+ return shadow_mask, shadow_pixels
832
+
833
+
834
+ def estimate_building_height(before_img, after_img, bbox, features):
835
+ """
836
+ Estimate building stories and height from shadow length and footprint geometry.
837
+ Returns (estimated_stories, estimated_height_m).
838
+ """
839
+ before_gray = cv2.cvtColor(before_img, cv2.COLOR_RGB2GRAY)
840
+ after_gray = cv2.cvtColor(after_img, cv2.COLOR_RGB2GRAY)
841
+ x, y, w, h = bbox
842
+
843
+ shadow_mask, shadow_px = _detect_shadow_region(before_gray, after_gray, bbox)
844
+
845
+ short_side = max(min(w, h), 1)
846
+ footprint_area = w * h
847
+
848
+ # --- Shadow-based estimate ---
849
+ shadow_ratio = 0.0
850
+ if shadow_mask is not None and shadow_px > 20:
851
+ # Measure max extent of shadow perpendicular to building edge
852
+ coords = np.column_stack(np.where(shadow_mask > 0))
853
+ if len(coords) > 5:
854
+ # Shadow length = extent along the longer axis of shadow cluster
855
+ spread_y = coords[:, 0].max() - coords[:, 0].min()
856
+ spread_x = coords[:, 1].max() - coords[:, 1].min()
857
+ shadow_length = max(spread_y, spread_x)
858
+ shadow_ratio = shadow_length / short_side
859
+
860
+ # --- Footprint-based estimate ---
861
+ aspect = max(w, h) / max(short_side, 1)
862
+ # Compact footprints (aspect < 2.5) tend to be multi-story; elongated are single-story
863
+ footprint_factor = 1.0
864
+ if aspect > 3.0:
865
+ footprint_factor = 0.5 # likely single-story warehouse/industrial
866
+ elif aspect < 1.5 and footprint_area > 2000:
867
+ footprint_factor = 1.3 # compact large footprint = likely taller
868
+
869
+ # --- Texture regularity bonus ---
870
+ # Buildings with low orientation entropy (regular structure) tend to be taller
871
+ regularity_bonus = 0.0
872
+ if features and features.get("orientation_entropy", 3.0) < 2.2:
873
+ regularity_bonus = 0.5
874
+
875
+ # --- Combine signals ---
876
+ # Base: shadow ratio maps ~0.3-0.5 per story in typical nadir imagery
877
+ if shadow_ratio > 0.1:
878
+ raw_stories = shadow_ratio / 0.35
879
+ else:
880
+ # No clear shadow: use footprint area as rough proxy
881
+ if footprint_area > 5000:
882
+ raw_stories = 3.0
883
+ elif footprint_area > 2000:
884
+ raw_stories = 2.0
885
+ else:
886
+ raw_stories = 1.0
887
+
888
+ raw_stories = raw_stories * footprint_factor + regularity_bonus
889
+ stories = max(1, min(50, int(round(raw_stories))))
890
+ height_m = round(stories * _STORY_HEIGHT_M, 1)
891
+
892
+ return stories, height_m
893
+
894
+
895
+ def classify_construction_stage(features, bbox):
896
+ """
897
+ Classify construction stage from visual features.
898
+ Returns (stage_name, confidence).
899
+ """
900
+ if features is None:
901
+ return "Unknown", 0.0
902
+
903
+ w, h = bbox[2], bbox[3]
904
+ area = w * h
905
+
906
+ scores = {
907
+ "Foundation": 0.0,
908
+ "Structural": 0.0,
909
+ "Under Construction": 0.0,
910
+ "Complete": 0.0,
911
+ }
912
+
913
+ tex = features.get("texture_std", 30)
914
+ edge = features.get("edge_density", 40)
915
+ orient = features.get("orientation_entropy", 2.5)
916
+ homog = features.get("color_homogeneity", 25)
917
+ bright = features.get("brightness", 60)
918
+ sat = features.get("saturation", 50)
919
+ glcm = features.get("glcm_contrast", 500)
920
+ lbp_var = features.get("lbp_variance", 0.04)
921
+
922
+ # --- Foundation ---
923
+ # Flat, low-texture, soil/concrete colored, homogeneous
924
+ if tex < 22:
925
+ scores["Foundation"] += 0.25
926
+ if edge < 30:
927
+ scores["Foundation"] += 0.20
928
+ if homog < 20:
929
+ scores["Foundation"] += 0.20
930
+ if 40 <= bright <= 75:
931
+ scores["Foundation"] += 0.15
932
+ if sat < 60:
933
+ scores["Foundation"] += 0.10
934
+ if lbp_var < 0.03:
935
+ scores["Foundation"] += 0.10
936
+
937
+ # --- Structural/Framing ---
938
+ # High edges, geometric regularity, high contrast grid patterns
939
+ if edge > 50:
940
+ scores["Structural"] += 0.25
941
+ if orient < 2.2:
942
+ scores["Structural"] += 0.20
943
+ if glcm > 800:
944
+ scores["Structural"] += 0.20
945
+ if tex > 30:
946
+ scores["Structural"] += 0.15
947
+ if homog > 30:
948
+ scores["Structural"] += 0.10
949
+ if area > 1000:
950
+ scores["Structural"] += 0.10
951
+
952
+ # --- Under Construction ---
953
+ # Mixed materials, irregular texture, medium-high edge density
954
+ if 25 < tex < 50:
955
+ scores["Under Construction"] += 0.20
956
+ if 35 < edge < 65:
957
+ scores["Under Construction"] += 0.20
958
+ if orient > 2.6:
959
+ scores["Under Construction"] += 0.20
960
+ if homog > 25:
961
+ scores["Under Construction"] += 0.15
962
+ if 0.03 < lbp_var < 0.07:
963
+ scores["Under Construction"] += 0.15
964
+ if sat < 80:
965
+ scores["Under Construction"] += 0.10
966
+
967
+ # --- Complete ---
968
+ # Uniform roof, clean edges, low entropy, consistent color
969
+ if tex < 28:
970
+ scores["Complete"] += 0.20
971
+ if orient < 2.3:
972
+ scores["Complete"] += 0.25
973
+ if homog < 22:
974
+ scores["Complete"] += 0.20
975
+ if edge > 25:
976
+ scores["Complete"] += 0.10
977
+ if lbp_var < 0.04:
978
+ scores["Complete"] += 0.15
979
+ if bright > 50:
980
+ scores["Complete"] += 0.10
981
+
982
+ best = max(scores, key=scores.get)
983
+ conf = scores[best]
984
+
985
+ if conf < 0.25:
986
+ return "Unknown", conf
987
+ return best, min(conf, 1.0)
988
+
989
+
990
+ def analyze_building_3d(before_img, after_img, region, features):
991
+ """
992
+ Run 3D analysis on a single building/construction region.
993
+ Enriches the region dict with stories, height, and construction stage.
994
+ """
995
+ bbox = region["bbox"]
996
+
997
+ stories, height_m = estimate_building_height(before_img, after_img, bbox, features)
998
+ stage, stage_conf = classify_construction_stage(features, bbox)
999
+
1000
+ region["estimated_stories"] = stories
1001
+ region["estimated_height_m"] = height_m
1002
+ region["construction_stage"] = stage
1003
+ region["construction_stage_confidence"] = stage_conf
1004
+ return region
1005
+
1006
+
1007
+ # ---------------------------------------------------------------------------
1008
+ # 12. Region analysis
1009
+ # ---------------------------------------------------------------------------
1010
+
1011
+ def analyze_change_regions(change_mask, image, min_area=200, use_ensemble=True,
1012
+ before_img=None):
1013
  """
1014
  Find connected change regions, classify as ground-level changes only.
1015
  Transient objects (people, cars, animals) are filtered out.
1016
+ Building regions get enriched with 3D analysis (stories, height, stage).
1017
  """
1018
  num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(change_mask, connectivity=8)
1019
  change_regions = []
 
1035
  else:
1036
  object_type, confidence = classify_object_type(image, (x, y, w, h))
1037
 
 
1038
  if object_type is None:
1039
  continue
1040
 
1041
  region_id += 1
1042
+ region = {
1043
  "id": region_id,
1044
  "area": area,
1045
  "bbox": (x, y, w, h),
1046
  "center": (int(cx), int(cy)),
1047
  "object_type": object_type,
1048
  "confidence": confidence,
1049
+ "estimated_stories": None,
1050
+ "estimated_height_m": None,
1051
+ "construction_stage": None,
1052
+ }
1053
+
1054
+ # 3D analysis for building/construction regions
1055
+ if object_type in _BUILDING_TYPES and before_img is not None:
1056
+ pad = 5
1057
+ ry1 = max(0, y - pad)
1058
+ ry2 = min(image.shape[0], y + h + pad)
1059
+ rx1 = max(0, x - pad)
1060
+ rx2 = min(image.shape[1], x + w + pad)
1061
+ crop = image[ry1:ry2, rx1:rx2]
1062
+ feats = extract_advanced_features(crop) if crop.size > 0 else None
1063
+ analyze_building_3d(before_img, image, region, feats)
1064
+
1065
+ change_regions.append(region)
1066
 
1067
  change_regions.sort(key=lambda r: r["area"], reverse=True)
1068
  return change_regions
 
1092
  else:
1093
  change_mask = hybrid_method(before_array, after_array)
1094
 
1095
+ change_regions = analyze_change_regions(
1096
+ change_mask, after_array, min_area=200, before_img=before_array
1097
+ )
1098
 
 
1099
  result_image = visualize_changes(before_array, after_array, change_mask, regions=change_regions)
1100
 
1101
  total_pixels = int(change_mask.shape[0] * change_mask.shape[1])
app/main.py CHANGED
@@ -205,6 +205,9 @@ async def detect(
205
  "bbox": {"x": int(r["bbox"][0]), "y": int(r["bbox"][1]), "w": int(r["bbox"][2]), "h": int(r["bbox"][3])},
206
  "objectType": str(r["object_type"]),
207
  "confidence": float(r["confidence"]),
 
 
 
208
  }
209
  for r in change_regions
210
  ]
 
205
  "bbox": {"x": int(r["bbox"][0]), "y": int(r["bbox"][1]), "w": int(r["bbox"][2]), "h": int(r["bbox"][3])},
206
  "objectType": str(r["object_type"]),
207
  "confidence": float(r["confidence"]),
208
+ "estimatedStories": r.get("estimated_stories"),
209
+ "estimatedHeightM": float(r["estimated_height_m"]) if r.get("estimated_height_m") is not None else None,
210
+ "constructionStage": r.get("construction_stage"),
211
  }
212
  for r in change_regions
213
  ]
static/js/app.js CHANGED
@@ -262,11 +262,17 @@ function showResult(data) {
262
  tbody.innerHTML = '';
263
  (data.regions || []).slice(0, 50).forEach((r) => {
264
  const tr = document.createElement('tr');
 
 
 
265
  tr.innerHTML = `
266
  <td>${r.id}</td>
267
  <td>${r.objectType}</td>
268
  <td>${(r.confidence * 100).toFixed(1)}%</td>
269
  <td>${r.area.toLocaleString()}</td>
 
 
 
270
  <td>(${r.center.x}, ${r.center.y})</td>
271
  `;
272
  tbody.appendChild(tr);
 
262
  tbody.innerHTML = '';
263
  (data.regions || []).slice(0, 50).forEach((r) => {
264
  const tr = document.createElement('tr');
265
+ const stories = r.estimatedStories != null ? r.estimatedStories : '—';
266
+ const height = r.estimatedHeightM != null ? r.estimatedHeightM + ' m' : '—';
267
+ const stage = r.constructionStage && r.constructionStage !== 'Unknown' ? r.constructionStage : '—';
268
  tr.innerHTML = `
269
  <td>${r.id}</td>
270
  <td>${r.objectType}</td>
271
  <td>${(r.confidence * 100).toFixed(1)}%</td>
272
  <td>${r.area.toLocaleString()}</td>
273
+ <td>${stories}</td>
274
+ <td>${height}</td>
275
+ <td>${stage}</td>
276
  <td>(${r.center.x}, ${r.center.y})</td>
277
  `;
278
  tbody.appendChild(tr);
templates/index.html CHANGED
@@ -253,6 +253,9 @@
253
  <th>Ground Change Type</th>
254
  <th>Confidence</th>
255
  <th>Area (px)</th>
 
 
 
256
  <th>Center</th>
257
  </tr>
258
  </thead>
@@ -288,6 +291,6 @@
288
  </div>
289
  </div>
290
 
291
- <script src="/static/js/app.js?v=10"></script>
292
  </body>
293
  </html>
 
253
  <th>Ground Change Type</th>
254
  <th>Confidence</th>
255
  <th>Area (px)</th>
256
+ <th>Stories</th>
257
+ <th>Height</th>
258
+ <th>Stage</th>
259
  <th>Center</th>
260
  </tr>
261
  </thead>
 
291
  </div>
292
  </div>
293
 
294
+ <script src="/static/js/app.js?v=11"></script>
295
  </body>
296
  </html>