Spaces:
Runtime error
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>
|
@@ -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=
|
| 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 |
-
#
|
| 1019 |
-
#
|
| 1020 |
perimeter = cv2.arcLength(contour, True)
|
| 1021 |
-
epsilon = 0.
|
| 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 |
-
|
| 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 |
-
|
| 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,
|
| 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(
|