Deagin Claude commited on
Commit
f4cfd4e
·
1 Parent(s): d2cc2e0

Add aggressive polygon simplification + Google roof segment visualization

Browse files

## Key Changes for Solar Panel Layout Tool

### 1. Increased Geometric Merge Tolerance
- Slope tolerance: 5° → 8° (more aggressive merging)
- Aspect tolerance: 20° → 30° (wider angle tolerance)
- Fixes shadow-split roof planes more effectively

### 2. Douglas-Peucker Polygon Simplification
- Epsilon increased: 0.5% → 2% of perimeter (4x more aggressive)
- Creates **straight edges** suitable for solar panel placement
- Eliminates "splotchy" boundaries shown in user feedback
- Output: Clean polygons with straight lines

### 3. Google Roof Segment Visualization (NEW)
- Added `visualize_google_roof_segments()` function
- Displays Google's pre-computed roof planes with:
- Color-coded segments
- Pitch and azimuth labels (e.g., "G0: 18° @ 279°")
- Replaced "Roof Planes Mask" output with "Google's Roof Segments (API)"
- Users can now compare custom segmentation vs Google's baseline

## User Requirements Addressed
- "Need better merge" → Increased geometric tolerance
- "Ramer-Douglas-Peucker to straighten splotches" → 2% epsilon
- "Clearly defined lines" → Straight polygon edges
- "Series of lat/lng coordinates" → Already in GeoJSON output
- "Visualize Google roof segments" → New visualization panel
- "Solar layout tool for panel placement" → Clean zones with straight edges

Expected result: 4-6 clean roof plane polygons with straight edges matching solar panel installation requirements.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +81 -7
app.py CHANGED
@@ -895,12 +895,14 @@ def refine_segments_with_features(segments, features, similarity_threshold=0.85,
895
  return merged_segments
896
 
897
 
898
- def merge_by_geometry(segments, dsm_array, slope_tolerance=5.0, aspect_tolerance=20.0):
899
  """
900
  Second merge pass: combine segments with same geometric orientation.
901
 
902
  This fixes the issue where shadows split roof planes - segments on the SAME
903
  geometric plane will merge even if they look different in RGB.
 
 
904
  """
905
  # Compute slope/aspect for the DSM
906
  slope, aspect, _, _, _ = compute_slope_aspect(dsm_array, pixel_size_meters=0.1)
@@ -996,6 +998,62 @@ def is_rectangle_matching_bounds(contour, img_width, img_height, tolerance=5):
996
  return matches_left and matches_top and matches_right and matches_bottom
997
 
998
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
999
  def mask_to_polygons(mask, bounds, img_width, img_height, min_area_sqft=50):
1000
  """Convert binary mask to GeoJSON polygons with simplified approach."""
1001
  features = []
@@ -1015,10 +1073,10 @@ def mask_to_polygons(mask, bounds, img_width, img_height, min_area_sqft=50):
1015
  if is_rectangle_matching_bounds(contour, img_width, img_height):
1016
  continue # Skip this contour (it's the building boundary, not a roof plane)
1017
 
1018
- # Simplified approach: Just Douglas-Peucker simplification
1019
- # Use conservative epsilon for cleaner but not over-simplified edges
1020
  perimeter = cv2.arcLength(contour, True)
1021
- epsilon = 0.005 * perimeter # 0.5% of perimeter - less aggressive
1022
  simplified = cv2.approxPolyDP(contour, epsilon, True)
1023
 
1024
  coords = []
@@ -1290,6 +1348,22 @@ def process_address(address, segmentation_method, n_segments, selected_clusters,
1290
  edge_viz = orig_array.copy()
1291
  edge_viz[edges > 0] = [255, 0, 0] # Red edges
1292
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1293
  total_sqft = sum(f["properties"]["area_sqft"] for f in polygon_features)
1294
  status += f"\n**Found {len(polygon_features)} roof plane polygon(s)**\n"
1295
  status += f"**Total roof area: {total_sqft:,.0f} sq ft**\n\n"
@@ -1310,7 +1384,7 @@ def process_address(address, segmentation_method, n_segments, selected_clusters,
1310
  status += f"- Segment {u}: {pct:.1f}%{marker}\n"
1311
 
1312
  return (np.array(image), overlay.astype(np.uint8), edge_viz.astype(np.uint8),
1313
- roof_mask, geojson_str, status)
1314
 
1315
  except Exception as e:
1316
  import traceback
@@ -1426,7 +1500,7 @@ with gr.Blocks(title="Roof Plane Segmentation - DINOv3", theme=gr.themes.Soft())
1426
 
1427
  with gr.Row():
1428
  edge_img = gr.Image(label="Detected Edges (Red)")
1429
- mask_img = gr.Image(label="Selected Roof Planes Mask")
1430
 
1431
  status_output = gr.Markdown()
1432
 
@@ -1440,7 +1514,7 @@ with gr.Blocks(title="Roof Plane Segmentation - DINOv3", theme=gr.themes.Soft())
1440
  inputs=[address_input, segmentation_method, n_segments, selected_clusters,
1441
  min_area, radius_meters, api_key_input,
1442
  hough_threshold, min_line_length, dsm_threshold, merge_threshold],
1443
- outputs=[original_img, overlay_img, edge_img, mask_img, geojson_output, status_output]
1444
  )
1445
 
1446
  download_btn.click(
 
895
  return merged_segments
896
 
897
 
898
+ def merge_by_geometry(segments, dsm_array, slope_tolerance=8.0, aspect_tolerance=30.0):
899
  """
900
  Second merge pass: combine segments with same geometric orientation.
901
 
902
  This fixes the issue where shadows split roof planes - segments on the SAME
903
  geometric plane will merge even if they look different in RGB.
904
+
905
+ Increased tolerances: slope ±8° (was 5°), aspect ±30° (was 20°)
906
  """
907
  # Compute slope/aspect for the DSM
908
  slope, aspect, _, _, _ = compute_slope_aspect(dsm_array, pixel_size_meters=0.1)
 
998
  return matches_left and matches_top and matches_right and matches_bottom
999
 
1000
 
1001
+ def visualize_google_roof_segments(google_segments, image_shape, bounds):
1002
+ """
1003
+ Visualize Google's pre-computed roof segments as colored overlay.
1004
+
1005
+ Returns visualization image showing Google's detected roof planes.
1006
+ """
1007
+ if not google_segments:
1008
+ return None
1009
+
1010
+ # Create blank canvas
1011
+ h, w = image_shape[:2]
1012
+ viz = np.zeros((h, w, 3), dtype=np.uint8)
1013
+
1014
+ colors = [
1015
+ [230, 25, 75], [60, 180, 75], [255, 225, 25], [0, 130, 200],
1016
+ [245, 130, 48], [145, 30, 180], [70, 240, 240], [240, 50, 230]
1017
+ ]
1018
+
1019
+ west, south, east, north = bounds
1020
+
1021
+ for idx, segment in enumerate(google_segments):
1022
+ bbox = segment.get('bounding_box', {})
1023
+ if not bbox:
1024
+ continue
1025
+
1026
+ # Convert lat/lng bounding box to pixel coordinates
1027
+ sw_lat = bbox.get('southwest', {}).get('latitude')
1028
+ sw_lng = bbox.get('southwest', {}).get('longitude')
1029
+ ne_lat = bbox.get('northeast', {}).get('latitude')
1030
+ ne_lng = bbox.get('northeast', {}).get('longitude')
1031
+
1032
+ if not all([sw_lat, sw_lng, ne_lat, ne_lng]):
1033
+ continue
1034
+
1035
+ # Convert to pixel coords
1036
+ x1 = int((sw_lng - west) / (east - west) * w)
1037
+ x2 = int((ne_lng - west) / (east - west) * w)
1038
+ y1 = int((north - ne_lat) / (north - south) * h) # Flip Y
1039
+ y2 = int((north - sw_lat) / (north - south) * h)
1040
+
1041
+ # Clamp to image bounds
1042
+ x1, x2 = max(0, min(x1, x2)), min(w, max(x1, x2))
1043
+ y1, y2 = max(0, min(y1, y2)), min(h, max(y1, y2))
1044
+
1045
+ # Draw filled rectangle
1046
+ color = colors[idx % len(colors)]
1047
+ cv2.rectangle(viz, (x1, y1), (x2, y2), color, -1)
1048
+
1049
+ # Add text label
1050
+ label = f"G{idx}: {segment['pitch_degrees']:.0f}° @ {segment['azimuth_degrees']:.0f}°"
1051
+ cv2.putText(viz, label, (x1 + 5, y1 + 20), cv2.FONT_HERSHEY_SIMPLEX,
1052
+ 0.4, (255, 255, 255), 1, cv2.LINE_AA)
1053
+
1054
+ return viz
1055
+
1056
+
1057
  def mask_to_polygons(mask, bounds, img_width, img_height, min_area_sqft=50):
1058
  """Convert binary mask to GeoJSON polygons with simplified approach."""
1059
  features = []
 
1073
  if is_rectangle_matching_bounds(contour, img_width, img_height):
1074
  continue # Skip this contour (it's the building boundary, not a roof plane)
1075
 
1076
+ # Douglas-Peucker simplification for STRAIGHT EDGES
1077
+ # Increased epsilon for cleaner polygons (straight lines for solar panels)
1078
  perimeter = cv2.arcLength(contour, True)
1079
+ epsilon = 0.02 * perimeter # 2% of perimeter - more aggressive for straight edges
1080
  simplified = cv2.approxPolyDP(contour, epsilon, True)
1081
 
1082
  coords = []
 
1348
  edge_viz = orig_array.copy()
1349
  edge_viz[edges > 0] = [255, 0, 0] # Red edges
1350
 
1351
+ # Google roof segments visualization
1352
+ google_viz = None
1353
+ if google_roof_segments:
1354
+ google_viz = visualize_google_roof_segments(
1355
+ google_roof_segments,
1356
+ orig_array.shape,
1357
+ bounds
1358
+ )
1359
+ if google_viz is not None:
1360
+ # Blend with original image
1361
+ google_viz = (orig_array * 0.4 + google_viz * 0.6).astype(np.uint8)
1362
+ else:
1363
+ google_viz = np.zeros_like(orig_array, dtype=np.uint8)
1364
+ else:
1365
+ google_viz = np.zeros_like(orig_array, dtype=np.uint8)
1366
+
1367
  total_sqft = sum(f["properties"]["area_sqft"] for f in polygon_features)
1368
  status += f"\n**Found {len(polygon_features)} roof plane polygon(s)**\n"
1369
  status += f"**Total roof area: {total_sqft:,.0f} sq ft**\n\n"
 
1384
  status += f"- Segment {u}: {pct:.1f}%{marker}\n"
1385
 
1386
  return (np.array(image), overlay.astype(np.uint8), edge_viz.astype(np.uint8),
1387
+ google_viz, geojson_str, status)
1388
 
1389
  except Exception as e:
1390
  import traceback
 
1500
 
1501
  with gr.Row():
1502
  edge_img = gr.Image(label="Detected Edges (Red)")
1503
+ google_img = gr.Image(label="Google's Roof Segments (API)")
1504
 
1505
  status_output = gr.Markdown()
1506
 
 
1514
  inputs=[address_input, segmentation_method, n_segments, selected_clusters,
1515
  min_area, radius_meters, api_key_input,
1516
  hough_threshold, min_line_length, dsm_threshold, merge_threshold],
1517
+ outputs=[original_img, overlay_img, edge_img, google_img, geojson_output, status_output]
1518
  )
1519
 
1520
  download_btn.click(