Spaces:
Runtime error
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>
|
@@ -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 |
-
|
| 843 |
-
|
| 844 |
-
|
| 845 |
-
|
| 846 |
-
|
| 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 |
-
|
| 857 |
-
|
| 858 |
-
|
| 859 |
-
|
| 860 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=["
|
| 1018 |
-
value="
|
| 1019 |
label="Segmentation Algorithm",
|
| 1020 |
-
info="Watershed
|
| 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(
|