Spaces:
Sleeping
Fix complex-roof segmentation: recover small planes, reject walls, handle solar
Browse filesCluster of fixes triggered by 8121 Golden Vista Way failing hard (4 planes on
what's really an 8+ plane multi-wing roof with existing solar).
- fusion.min_fraction 0.05 -> 0.02
5% of building area was eating legitimate hip caps and wing sections on
complex roofs. 2% keeps them.
- radio_backbone: add solar panel prompts, count them as roof
ROOF_PROMPTS gains "solar panel array on roof" and "photovoltaic panels
installed on roof". Existing solar was being pushed toward "wall"/"shadow"
by RADIO, causing fusion to carve holes in the roof mask (41% roof
classification on parents' house vs 73% on a bare-roof reference).
- ransac_planes: reject planes with pitch > 65deg
RANSAC was fitting the occasional facade edge in the DSM as a "plane"
at 70-80deg pitch. Consume the inliers so we don't refit, but don't emit.
- geo_export: polygon simplification epsilon 0.025 -> 0.010
Hip caps and dormers were being collapsed into straight lines at 2.5% of
perimeter. Tighter tolerance preserves architectural detail.
- radio_backbone.get_roof_mask defaults to len(ROOF_PROMPTS)
so future prompt additions don't require touching call sites.
Result on 8121 Golden Vista: RANSAC 7 -> final 10 planes exported.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- fusion.py +6 -2
- geo_export.py +4 -2
- pipeline.py +1 -1
- radio_backbone.py +10 -2
- ransac_planes.py +8 -1
|
@@ -14,7 +14,7 @@ def fuse_segmentations(
|
|
| 14 |
ransac_labels: np.ndarray,
|
| 15 |
radio_scores: np.ndarray,
|
| 16 |
building_mask: np.ndarray,
|
| 17 |
-
num_roof_classes: int =
|
| 18 |
) -> np.ndarray:
|
| 19 |
"""Merge RANSAC plane labels with RADIO appearance scores.
|
| 20 |
|
|
@@ -110,11 +110,15 @@ def split_disconnected_regions(label_map: np.ndarray) -> np.ndarray:
|
|
| 110 |
def merge_small_fragments(
|
| 111 |
label_map: np.ndarray,
|
| 112 |
building_mask: np.ndarray,
|
| 113 |
-
min_fraction: float = 0.
|
| 114 |
) -> np.ndarray:
|
| 115 |
"""Merge fragments smaller than min_fraction of building area.
|
| 116 |
|
| 117 |
Small fragments get absorbed into their nearest neighboring plane.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
"""
|
| 119 |
building_area = (building_mask > 0).sum()
|
| 120 |
min_pixels = int(building_area * min_fraction)
|
|
|
|
| 14 |
ransac_labels: np.ndarray,
|
| 15 |
radio_scores: np.ndarray,
|
| 16 |
building_mask: np.ndarray,
|
| 17 |
+
num_roof_classes: int = 6,
|
| 18 |
) -> np.ndarray:
|
| 19 |
"""Merge RANSAC plane labels with RADIO appearance scores.
|
| 20 |
|
|
|
|
| 110 |
def merge_small_fragments(
|
| 111 |
label_map: np.ndarray,
|
| 112 |
building_mask: np.ndarray,
|
| 113 |
+
min_fraction: float = 0.02,
|
| 114 |
) -> np.ndarray:
|
| 115 |
"""Merge fragments smaller than min_fraction of building area.
|
| 116 |
|
| 117 |
Small fragments get absorbed into their nearest neighboring plane.
|
| 118 |
+
|
| 119 |
+
Default 0.02 (2%) preserves real hip caps and small wings on complex roofs.
|
| 120 |
+
Higher values (0.05) merged legitimate small planes into their neighbors —
|
| 121 |
+
see 8121 Golden Vista Way for a failure case at 0.05.
|
| 122 |
"""
|
| 123 |
building_area = (building_mask > 0).sum()
|
| 124 |
min_pixels = int(building_area * min_fraction)
|
|
@@ -101,9 +101,11 @@ def labels_to_geojson(
|
|
| 101 |
if area_sqft < min_area_sqft:
|
| 102 |
continue
|
| 103 |
|
| 104 |
-
# Simplify polygon
|
|
|
|
|
|
|
| 105 |
perimeter = cv2.arcLength(contour, True)
|
| 106 |
-
epsilon = 0.
|
| 107 |
simplified = cv2.approxPolyDP(contour, epsilon, True)
|
| 108 |
|
| 109 |
coords = [pixel_to_geo(pt[0][0], pt[0][1], w, h, bounds) for pt in simplified]
|
|
|
|
| 101 |
if area_sqft < min_area_sqft:
|
| 102 |
continue
|
| 103 |
|
| 104 |
+
# Simplify polygon. Lower epsilon (1% vs previous 2.5%) preserves
|
| 105 |
+
# real geometry on complex roofs — hip caps and dormers were being
|
| 106 |
+
# collapsed into straight lines at 2.5%.
|
| 107 |
perimeter = cv2.arcLength(contour, True)
|
| 108 |
+
epsilon = 0.010 * perimeter
|
| 109 |
simplified = cv2.approxPolyDP(contour, epsilon, True)
|
| 110 |
|
| 111 |
coords = [pixel_to_geo(pt[0][0], pt[0][1], w, h, bounds) for pt in simplified]
|
|
@@ -158,7 +158,7 @@ def run(
|
|
| 158 |
result.status.append("Fusing geometry + appearance...")
|
| 159 |
fused = fuse_segmentations(ransac_labels, score_map, cropped_mask)
|
| 160 |
fused = split_disconnected_regions(fused)
|
| 161 |
-
fused = merge_small_fragments(fused, cropped_mask, min_fraction=0.
|
| 162 |
result.fused_labels = fused
|
| 163 |
|
| 164 |
n_final = len(set(np.unique(fused)) - {0})
|
|
|
|
| 158 |
result.status.append("Fusing geometry + appearance...")
|
| 159 |
fused = fuse_segmentations(ransac_labels, score_map, cropped_mask)
|
| 160 |
fused = split_disconnected_regions(fused)
|
| 161 |
+
fused = merge_small_fragments(fused, cropped_mask, min_fraction=0.02)
|
| 162 |
result.fused_labels = fused
|
| 163 |
|
| 164 |
n_final = len(set(np.unique(fused)) - {0})
|
|
@@ -18,12 +18,17 @@ from einops import rearrange
|
|
| 18 |
|
| 19 |
PATCH_SIZE = 16
|
| 20 |
|
| 21 |
-
# Zero-shot text prompts for roof segmentation
|
|
|
|
|
|
|
|
|
|
| 22 |
ROOF_PROMPTS = [
|
| 23 |
"flat roof plane",
|
| 24 |
"pitched roof plane",
|
| 25 |
"hip roof plane",
|
| 26 |
"gable roof plane",
|
|
|
|
|
|
|
| 27 |
]
|
| 28 |
NON_ROOF_PROMPTS = [
|
| 29 |
"sky",
|
|
@@ -259,9 +264,12 @@ def zero_shot_segment(
|
|
| 259 |
return score_map_np, seg_map, all_labels
|
| 260 |
|
| 261 |
|
| 262 |
-
def get_roof_mask(seg_map: np.ndarray, num_roof_classes: int =
|
| 263 |
"""Extract binary roof mask from segmentation map.
|
| 264 |
|
| 265 |
Assumes first num_roof_classes indices in the label list are roof types.
|
|
|
|
| 266 |
"""
|
|
|
|
|
|
|
| 267 |
return (seg_map < num_roof_classes).astype(np.uint8)
|
|
|
|
| 18 |
|
| 19 |
PATCH_SIZE = 16
|
| 20 |
|
| 21 |
+
# Zero-shot text prompts for roof segmentation.
|
| 22 |
+
# Solar-array prompts count as "roof" — the pixels underneath still belong
|
| 23 |
+
# to a roof plane; without these prompts, RADIO pushes solar-covered
|
| 24 |
+
# pixels toward "wall"/"shadow" and fusion carves holes in the roof mask.
|
| 25 |
ROOF_PROMPTS = [
|
| 26 |
"flat roof plane",
|
| 27 |
"pitched roof plane",
|
| 28 |
"hip roof plane",
|
| 29 |
"gable roof plane",
|
| 30 |
+
"solar panel array on roof",
|
| 31 |
+
"photovoltaic panels installed on roof",
|
| 32 |
]
|
| 33 |
NON_ROOF_PROMPTS = [
|
| 34 |
"sky",
|
|
|
|
| 264 |
return score_map_np, seg_map, all_labels
|
| 265 |
|
| 266 |
|
| 267 |
+
def get_roof_mask(seg_map: np.ndarray, num_roof_classes: int = None) -> np.ndarray:
|
| 268 |
"""Extract binary roof mask from segmentation map.
|
| 269 |
|
| 270 |
Assumes first num_roof_classes indices in the label list are roof types.
|
| 271 |
+
Defaults to len(ROOF_PROMPTS) so this stays correct if the prompt list changes.
|
| 272 |
"""
|
| 273 |
+
if num_roof_classes is None:
|
| 274 |
+
num_roof_classes = len(ROOF_PROMPTS)
|
| 275 |
return (seg_map < num_roof_classes).astype(np.uint8)
|
|
@@ -154,8 +154,15 @@ def fit_planes(
|
|
| 154 |
pitch_deg = float(np.degrees(np.arccos(np.clip(abs(refined_normal[2]), 0, 1))))
|
| 155 |
azimuth_deg = float(np.degrees(np.arctan2(-refined_normal[0], -refined_normal[1])) % 360)
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
planes.append({
|
| 158 |
-
"segment_id":
|
| 159 |
"equation": [float(refined_normal[0]), float(refined_normal[1]),
|
| 160 |
float(refined_normal[2]), float(refined_d)],
|
| 161 |
"normal": refined_normal.tolist(),
|
|
|
|
| 154 |
pitch_deg = float(np.degrees(np.arccos(np.clip(abs(refined_normal[2]), 0, 1))))
|
| 155 |
azimuth_deg = float(np.degrees(np.arctan2(-refined_normal[0], -refined_normal[1])) % 360)
|
| 156 |
|
| 157 |
+
# Reject wall-like fits — roofs top out around 60° pitch (12/12 = 45°).
|
| 158 |
+
# Anything steeper is almost certainly RANSAC finding a facade edge in the DSM.
|
| 159 |
+
if pitch_deg > 65.0:
|
| 160 |
+
# Still consume the inliers so we don't re-fit the same wall,
|
| 161 |
+
# but don't emit this plane.
|
| 162 |
+
continue
|
| 163 |
+
|
| 164 |
planes.append({
|
| 165 |
+
"segment_id": len(planes) + 1,
|
| 166 |
"equation": [float(refined_normal[0]), float(refined_normal[1]),
|
| 167 |
float(refined_normal[2]), float(refined_d)],
|
| 168 |
"normal": refined_normal.tolist(),
|