Deagin Claude Opus 4.7 (1M context) commited on
Commit
88b423e
·
1 Parent(s): fe58fef

Fix complex-roof segmentation: recover small planes, reject walls, handle solar

Browse files

Cluster 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>

Files changed (5) hide show
  1. fusion.py +6 -2
  2. geo_export.py +4 -2
  3. pipeline.py +1 -1
  4. radio_backbone.py +10 -2
  5. ransac_planes.py +8 -1
fusion.py CHANGED
@@ -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 = 4,
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.05,
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)
geo_export.py CHANGED
@@ -101,9 +101,11 @@ def labels_to_geojson(
101
  if area_sqft < min_area_sqft:
102
  continue
103
 
104
- # Simplify polygon (2.5% of perimeter — clean roof plane shapes)
 
 
105
  perimeter = cv2.arcLength(contour, True)
106
- epsilon = 0.025 * perimeter
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]
pipeline.py CHANGED
@@ -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.05)
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})
radio_backbone.py CHANGED
@@ -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 = 4) -> np.ndarray:
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)
ransac_planes.py CHANGED
@@ -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": plane_idx + 1,
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(),