Deagin Claude commited on
Commit
5ad10c2
·
1 Parent(s): 73fbf43

Implement proper DSM-based roof plane segmentation + Google Building Insights API

Browse files

**Major Breakthrough**: Replaced edge detection approach with proper geometric plane segmentation.

## What We Were Doing Wrong
- Using RGB edge detection (Hough lines, Canny) to find roof planes
- 2D image processing for a 3D geometry problem
- Getting "angry scribbling" with too many false lines

## The Proper Solution (Based on Research)
1. **Google Building Insights API**: Fetch pre-computed roof segments
- Returns pitch, azimuth, bounding box for each roof plane
- Available as baseline/comparison

2. **DSM Slope/Aspect Segmentation** (NEW):
- Compute surface normals from Digital Surface Model (slope + aspect)
- Region growing: pixels with similar normals = same roof plane
- This is the geometric method used by solar companies
- No edge detection needed - pure 3D geometry

## New Features
- `fetch_building_insights()`: Get Google's roof segments (pitch, azimuth, area)
- `compute_slope_aspect()`: Calculate surface normals from DSM
- `segment_roof_planes_dsm()`: Region growing based on normal similarity
- UI now defaults to "DSM" method (geometric) vs "Watershed" (DINOv3 features)

## Research References
- Google "Satellite Sunroof" paper (2024)
- RANSAC plane fitting for LiDAR point clouds
- Standard: slope/aspect clustering for roof plane detection

User can now choose:
- DSM (geometric, recommended)
- Watershed/SLIC/Felzenszwalb (DINOv3 semantic features)

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

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

Files changed (1) hide show
  1. app.py +286 -21
app.py CHANGED
@@ -72,6 +72,50 @@ def geocode_address(address, api_key):
72
  return location["lat"], location["lng"], formatted_address
73
 
74
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
75
  def fetch_geotiff(lat, lng, api_key, radius_meters=50):
76
  """Fetch RGB GeoTIFF and building mask from Google Solar API Data Layers."""
77
 
@@ -328,6 +372,185 @@ def detect_and_suppress_shadows(img_array):
328
  return img_corrected, shadow_mask
329
 
330
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  def extract_multiscale_features(image, target_size=518):
332
  """Extract multi-layer DINOv3 features for better roof plane detection."""
333
  original_size = image.size
@@ -778,6 +1001,24 @@ def process_address(address, segmentation_method, n_segments, selected_clusters,
778
  except Exception as e:
779
  return None, None, None, None, None, f"❌ Geocoding failed: {str(e)}"
780
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
781
  try:
782
  status += "Fetching satellite imagery and building data...\n"
783
  geotiff_bytes, mask_bytes, dsm_bytes, layers_info = fetch_geotiff(lat, lng, api_key, radius_meters)
@@ -839,25 +1080,49 @@ def process_address(address, segmentation_method, n_segments, selected_clusters,
839
  return None, None, None, None, None, f"❌ Failed to fetch imagery: {str(e)}"
840
 
841
  try:
842
- status += f"**Model:** {MODEL_NAME}\n"
843
- status += f"Running {segmentation_method.upper()} segmentation with shadow suppression...\n"
844
-
845
- seg_resized, img_array, edges, shadow_mask = segment_roof_planes(
846
- image,
847
- method=segmentation_method,
848
- n_segments=int(n_segments),
849
- dsm_array=dsm_array,
850
- building_mask=cropped_mask,
851
- hough_threshold=hough_threshold,
852
- min_line_length=min_line_length,
853
- dsm_threshold=dsm_threshold
854
- )
855
 
856
- status += f"✓ Detected {len(np.unique(seg_resized))} segments\n"
857
- if shadow_mask is not None and shadow_mask.any():
858
- shadow_pct = (shadow_mask.sum() / shadow_mask.size) * 100
859
- status += f"✓ Shadow regions: {shadow_pct:.1f}% (suppressed)\n"
860
- status += f"\n"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
861
 
862
  # Visualize segmentation
863
  colors = np.array([
@@ -1014,10 +1279,10 @@ with gr.Blocks(title="Roof Plane Segmentation - DINOv3", theme=gr.themes.Soft())
1014
 
1015
  with gr.Accordion("⚙️ Segmentation Settings", open=True):
1016
  segmentation_method = gr.Radio(
1017
- choices=["slic", "watershed", "felzenszwalb"],
1018
- value="watershed",
1019
  label="Segmentation Algorithm",
1020
- info="Watershed best for roof ridges/valleys"
1021
  )
1022
 
1023
  n_segments = gr.Slider(
 
72
  return location["lat"], location["lng"], formatted_address
73
 
74
 
75
+ def fetch_building_insights(lat, lng, api_key):
76
+ """Fetch Google's pre-computed roof segments from Building Insights API."""
77
+ url = "https://solar.googleapis.com/v1/buildingInsights:findClosest"
78
+ params = {
79
+ "location.latitude": lat,
80
+ "location.longitude": lng,
81
+ "requiredQuality": "HIGH",
82
+ "key": api_key
83
+ }
84
+
85
+ response = requests.get(url, params=params)
86
+
87
+ if response.status_code != 200:
88
+ # Try MEDIUM quality if HIGH fails
89
+ params["requiredQuality"] = "MEDIUM"
90
+ response = requests.get(url, params=params)
91
+
92
+ if response.status_code != 200:
93
+ print(f"⚠️ Building Insights API error: {response.status_code}")
94
+ return None
95
+
96
+ data = response.json()
97
+
98
+ # Extract roof segments
99
+ roof_segments = []
100
+ if "solarPotential" in data and "roofSegmentStats" in data["solarPotential"]:
101
+ for idx, segment in enumerate(data["solarPotential"]["roofSegmentStats"]):
102
+ roof_segments.append({
103
+ "segment_id": idx,
104
+ "pitch_degrees": segment.get("pitchDegrees", 0),
105
+ "azimuth_degrees": segment.get("azimuthDegrees", 0),
106
+ "center": segment.get("center", {}),
107
+ "bounding_box": segment.get("boundingBox", {}),
108
+ "plane_height_meters": segment.get("planeHeightAtCenterMeters", 0),
109
+ "area_sqm": segment.get("stats", {}).get("areaMeters2", 0)
110
+ })
111
+
112
+ return {
113
+ "roof_segments": roof_segments,
114
+ "building_bounds": data.get("boundingBox", {}),
115
+ "raw_data": data
116
+ }
117
+
118
+
119
  def fetch_geotiff(lat, lng, api_key, radius_meters=50):
120
  """Fetch RGB GeoTIFF and building mask from Google Solar API Data Layers."""
121
 
 
372
  return img_corrected, shadow_mask
373
 
374
 
375
+ def compute_slope_aspect(dsm_array, pixel_size_meters=0.1):
376
+ """
377
+ Compute slope and aspect (surface normal) from DSM.
378
+
379
+ This is the KEY to proper roof plane segmentation - pixels with similar
380
+ normals belong to the same roof plane.
381
+
382
+ Args:
383
+ dsm_array: Digital Surface Model (height map)
384
+ pixel_size_meters: Ground sampling distance
385
+
386
+ Returns:
387
+ slope: Slope angle in degrees (0-90)
388
+ aspect: Aspect angle in degrees (0-360, compass direction)
389
+ normal_x, normal_y, normal_z: Surface normal vectors (unit vectors)
390
+ """
391
+ # Compute gradients (height change per pixel)
392
+ dy, dx = np.gradient(dsm_array)
393
+
394
+ # Convert to slope (change in height per meter)
395
+ dx = dx / pixel_size_meters
396
+ dy = dy / pixel_size_meters
397
+
398
+ # Compute slope (angle from horizontal)
399
+ slope_rad = np.arctan(np.sqrt(dx**2 + dy**2))
400
+ slope = np.degrees(slope_rad)
401
+
402
+ # Compute aspect (compass direction of steepest descent)
403
+ # 0° = North, 90° = East, 180° = South, 270° = West
404
+ aspect_rad = np.arctan2(-dx, dy) # Note: -dx because East is positive X
405
+ aspect = np.degrees(aspect_rad)
406
+ aspect = (aspect + 360) % 360 # Normalize to 0-360
407
+
408
+ # Compute surface normal vectors (unit vectors)
409
+ # Normal vector = (-dz/dx, -dz/dy, 1) normalized
410
+ normal_x = -dx
411
+ normal_y = -dy
412
+ normal_z = np.ones_like(dx)
413
+
414
+ # Normalize to unit vectors
415
+ magnitude = np.sqrt(normal_x**2 + normal_y**2 + normal_z**2)
416
+ normal_x = normal_x / magnitude
417
+ normal_y = normal_y / magnitude
418
+ normal_z = normal_z / magnitude
419
+
420
+ return slope, aspect, normal_x, normal_y, normal_z
421
+
422
+
423
+ def segment_roof_planes_dsm(dsm_array, building_mask=None, pixel_size_meters=0.1,
424
+ slope_tolerance=5.0, aspect_tolerance=15.0, min_area_pixels=100):
425
+ """
426
+ Segment roof planes based on DSM slope/aspect analysis.
427
+
428
+ This is the PROPER algorithm used by solar companies:
429
+ 1. Compute surface normals (slope + aspect) from DSM
430
+ 2. Cluster pixels with similar normals
431
+ 3. Extract planar roof segments
432
+
433
+ Args:
434
+ dsm_array: Digital Surface Model
435
+ building_mask: Restrict to building footprint
436
+ pixel_size_meters: Ground sampling distance
437
+ slope_tolerance: Max slope difference for same plane (degrees)
438
+ aspect_tolerance: Max aspect difference for same plane (degrees)
439
+ min_area_pixels: Minimum segment size
440
+
441
+ Returns:
442
+ segments: Labeled segmentation map
443
+ plane_info: List of plane parameters for each segment
444
+ """
445
+ # Compute surface normals
446
+ slope, aspect, normal_x, normal_y, normal_z = compute_slope_aspect(dsm_array, pixel_size_meters)
447
+
448
+ # Apply building mask
449
+ if building_mask is not None:
450
+ if building_mask.shape != dsm_array.shape:
451
+ building_mask = cv2.resize(
452
+ building_mask.astype(np.uint8),
453
+ (dsm_array.shape[1], dsm_array.shape[0]),
454
+ interpolation=cv2.INTER_NEAREST
455
+ )
456
+ valid_mask = building_mask > 0
457
+ else:
458
+ valid_mask = np.ones_like(dsm_array, dtype=bool)
459
+
460
+ # Stack features for clustering: (slope, aspect, height, normal_x, normal_y, normal_z)
461
+ # Normalize features to similar scales
462
+ features = np.stack([
463
+ slope / 90.0, # Normalize to 0-1
464
+ np.sin(np.radians(aspect)), # Convert aspect to cyclic features
465
+ np.cos(np.radians(aspect)),
466
+ (dsm_array - dsm_array[valid_mask].min()) / (dsm_array[valid_mask].max() - dsm_array[valid_mask].min() + 1e-8),
467
+ normal_x,
468
+ normal_y,
469
+ normal_z
470
+ ], axis=-1)
471
+
472
+ # Region growing based on normal similarity
473
+ segments = np.zeros_like(dsm_array, dtype=np.int32)
474
+ segment_id = 1
475
+ visited = ~valid_mask # Start with invalid pixels as visited
476
+
477
+ h, w = dsm_array.shape
478
+
479
+ # Find seed points (local maxima in height within building)
480
+ from scipy.ndimage import maximum_filter
481
+ local_max = maximum_filter(dsm_array, size=5)
482
+ seeds = (dsm_array == local_max) & valid_mask
483
+ seed_coords = np.argwhere(seeds)
484
+
485
+ # Region growing from seeds
486
+ for seed_y, seed_x in seed_coords:
487
+ if visited[seed_y, seed_x]:
488
+ continue
489
+
490
+ # Start new segment
491
+ stack = [(seed_y, seed_x)]
492
+ visited[seed_y, seed_x] = True
493
+ segment_pixels = []
494
+
495
+ seed_slope = slope[seed_y, seed_x]
496
+ seed_aspect = aspect[seed_y, seed_x]
497
+
498
+ while stack:
499
+ y, x = stack.pop()
500
+ segment_pixels.append((y, x))
501
+ segments[y, x] = segment_id
502
+
503
+ # Check 8-connected neighbors
504
+ for dy in [-1, 0, 1]:
505
+ for dx in [-1, 0, 1]:
506
+ if dy == 0 and dx == 0:
507
+ continue
508
+
509
+ ny, nx = y + dy, x + dx
510
+
511
+ if ny < 0 or ny >= h or nx < 0 or nx >= w:
512
+ continue
513
+ if visited[ny, nx]:
514
+ continue
515
+ if not valid_mask[ny, nx]:
516
+ continue
517
+
518
+ # Check if normal is similar (same roof plane)
519
+ slope_diff = abs(slope[ny, nx] - seed_slope)
520
+ aspect_diff = min(abs(aspect[ny, nx] - seed_aspect),
521
+ 360 - abs(aspect[ny, nx] - seed_aspect)) # Handle wraparound
522
+
523
+ if slope_diff < slope_tolerance and aspect_diff < aspect_tolerance:
524
+ visited[ny, nx] = True
525
+ stack.append((ny, nx))
526
+
527
+ # Keep segment if large enough
528
+ if len(segment_pixels) >= min_area_pixels:
529
+ segment_id += 1
530
+ else:
531
+ # Remove small segment
532
+ for y, x in segment_pixels:
533
+ segments[y, x] = 0
534
+
535
+ # Extract plane parameters for each segment
536
+ plane_info = []
537
+ for sid in range(1, segment_id):
538
+ mask = segments == sid
539
+ if not mask.any():
540
+ continue
541
+
542
+ plane_info.append({
543
+ "segment_id": sid,
544
+ "mean_slope": float(slope[mask].mean()),
545
+ "mean_aspect": float(aspect[mask].mean()),
546
+ "mean_height": float(dsm_array[mask].mean()),
547
+ "area_pixels": int(mask.sum()),
548
+ "area_sqm": float(mask.sum() * pixel_size_meters**2)
549
+ })
550
+
551
+ return segments, plane_info
552
+
553
+
554
  def extract_multiscale_features(image, target_size=518):
555
  """Extract multi-layer DINOv3 features for better roof plane detection."""
556
  original_size = image.size
 
1001
  except Exception as e:
1002
  return None, None, None, None, None, f"❌ Geocoding failed: {str(e)}"
1003
 
1004
+ # Fetch Google's pre-computed roof segments
1005
+ google_roof_segments = None
1006
+ try:
1007
+ status += "Fetching Google's roof segments from Building Insights API...\n"
1008
+ building_insights = fetch_building_insights(lat, lng, api_key)
1009
+ if building_insights and building_insights["roof_segments"]:
1010
+ google_roof_segments = building_insights["roof_segments"]
1011
+ status += f"✓ Google detected {len(google_roof_segments)} roof segments:\n"
1012
+ for seg in google_roof_segments[:5]: # Show first 5
1013
+ status += f" - Segment {seg['segment_id']}: {seg['pitch_degrees']:.1f}° pitch, {seg['azimuth_degrees']:.0f}° azimuth, {seg['area_sqm']:.1f} m²\n"
1014
+ if len(google_roof_segments) > 5:
1015
+ status += f" ... and {len(google_roof_segments) - 5} more\n"
1016
+ status += "\n"
1017
+ else:
1018
+ status += "⚠️ No Google roof segments available (will use custom segmentation)\n\n"
1019
+ except Exception as e:
1020
+ status += f"⚠️ Building Insights API error: {str(e)}\n\n"
1021
+
1022
  try:
1023
  status += "Fetching satellite imagery and building data...\n"
1024
  geotiff_bytes, mask_bytes, dsm_bytes, layers_info = fetch_geotiff(lat, lng, api_key, radius_meters)
 
1080
  return None, None, None, None, None, f"❌ Failed to fetch imagery: {str(e)}"
1081
 
1082
  try:
1083
+ # Choose segmentation method
1084
+ if segmentation_method == "dsm" and dsm_array is not None:
1085
+ # DSM-based slope/aspect segmentation (proper geometric method)
1086
+ status += f"**Method:** DSM Slope/Aspect Analysis (geometric)\n"
1087
+ status += f"Segmenting roof planes based on surface normals...\n"
 
 
 
 
 
 
 
 
1088
 
1089
+ seg_resized, plane_info = segment_roof_planes_dsm(
1090
+ dsm_array,
1091
+ building_mask=cropped_mask,
1092
+ pixel_size_meters=0.1
1093
+ )
1094
+
1095
+ status += f"✓ Detected {len(plane_info)} roof planes:\n"
1096
+ for plane in plane_info[:10]:
1097
+ status += f" - Plane {plane['segment_id']}: {plane['mean_slope']:.1f}° slope, {plane['mean_aspect']:.0f}° azimuth, {plane['area_sqm']:.1f} m²\n"
1098
+ status += f"\n"
1099
+
1100
+ # Create dummy edge and shadow mask for visualization
1101
+ img_array = np.array(image)
1102
+ edges = np.zeros(img_array.shape[:2], dtype=np.uint8)
1103
+ shadow_mask = None
1104
+
1105
+ else:
1106
+ # DINOv3-based semantic segmentation (existing method)
1107
+ status += f"**Model:** {MODEL_NAME}\n"
1108
+ status += f"Running {segmentation_method.upper()} segmentation with shadow suppression...\n"
1109
+
1110
+ seg_resized, img_array, edges, shadow_mask = segment_roof_planes(
1111
+ image,
1112
+ method=segmentation_method,
1113
+ n_segments=int(n_segments),
1114
+ dsm_array=dsm_array,
1115
+ building_mask=cropped_mask,
1116
+ hough_threshold=hough_threshold,
1117
+ min_line_length=min_line_length,
1118
+ dsm_threshold=dsm_threshold
1119
+ )
1120
+
1121
+ status += f"✓ Detected {len(np.unique(seg_resized))} segments\n"
1122
+ if shadow_mask is not None and shadow_mask.any():
1123
+ shadow_pct = (shadow_mask.sum() / shadow_mask.size) * 100
1124
+ status += f"✓ Shadow regions: {shadow_pct:.1f}% (suppressed)\n"
1125
+ status += f"\n"
1126
 
1127
  # Visualize segmentation
1128
  colors = np.array([
 
1279
 
1280
  with gr.Accordion("⚙️ Segmentation Settings", open=True):
1281
  segmentation_method = gr.Radio(
1282
+ choices=["dsm", "watershed", "slic", "felzenszwalb"],
1283
+ value="dsm",
1284
  label="Segmentation Algorithm",
1285
+ info="DSM = Slope/Aspect analysis (geometric, recommended). Watershed = DINOv3 features + height edges"
1286
  )
1287
 
1288
  n_segments = gr.Slider(