"""RANSAC 3D plane fitting for DSM-based roof segmentation. Fits geometric planes to the Digital Surface Model point cloud, producing clean plane boundaries that are completely shadow-invariant. """ import numpy as np import cv2 from scipy.ndimage import distance_transform_edt PIXEL_SIZE_M = 0.1 # Google Solar API resolution (10 cm/pixel) def preprocess_dsm(dsm: np.ndarray, building_mask: np.ndarray | None = None) -> np.ndarray: """Smooth DSM while preserving ridge edges. 1. Median filter (3x3) removes outlier spikes. 2. Bilateral filter preserves edges while smoothing flat areas. """ dsm_clean = cv2.medianBlur(dsm.astype(np.float32), 3) height_range = dsm_clean.max() - dsm_clean.min() if height_range < 1e-6: return dsm_clean # Bilateral filter operates on uint8, so normalize dsm_norm = ((dsm_clean - dsm_clean.min()) / height_range * 255).astype(np.uint8) dsm_filtered = cv2.bilateralFilter(dsm_norm, d=9, sigmaColor=50, sigmaSpace=9) # Denormalize back to meters dsm_smooth = (dsm_filtered.astype(np.float32) / 255.0) * height_range + dsm_clean.min() return dsm_smooth def dsm_to_point_cloud( dsm: np.ndarray, building_mask: np.ndarray, pixel_size: float = PIXEL_SIZE_M, ) -> tuple[np.ndarray, np.ndarray, np.ndarray]: """Convert DSM pixels within building mask to a 3D point cloud. Args: dsm: Height map (H, W) in meters. building_mask: Binary mask (H, W). pixel_size: Meters per pixel. Returns: (points_3d: Nx3, valid_flat_indices: N, valid_mask_flat: M bool) points_3d columns are [x_meters, y_meters, height]. """ h, w = dsm.shape if building_mask.shape != (h, w): building_mask = cv2.resize( building_mask.astype(np.uint8), (w, h), interpolation=cv2.INTER_NEAREST, ) valid_mask = building_mask > 0 valid_flat = valid_mask.flatten() yy, xx = np.mgrid[0:h, 0:w] xx_m = (xx * pixel_size).flatten()[valid_flat] yy_m = (yy * pixel_size).flatten()[valid_flat] zz = dsm.flatten()[valid_flat] points_3d = np.column_stack([xx_m, yy_m, zz]) valid_flat_indices = np.where(valid_flat)[0] return points_3d, valid_flat_indices, valid_flat def fit_planes( points_3d: np.ndarray, distance_threshold: float = 0.15, min_points: int = 500, max_planes: int = 8, n_iterations: int = 1000, ) -> list[dict]: """Iterative RANSAC plane fitting with SVD refinement. For each iteration: 1. Sample 3 random points, compute plane via cross product. 2. Count inliers within distance_threshold. 3. Keep best hypothesis. 4. Refit with SVD least-squares on all inliers. 5. Remove inliers, repeat. Returns: List of plane dicts with keys: equation, normal, inlier_indices, pitch_deg, azimuth_deg, mean_height, num_points. """ remaining_mask = np.ones(len(points_3d), dtype=bool) planes = [] for plane_idx in range(max_planes): remaining_indices = np.where(remaining_mask)[0] remaining_pts = points_3d[remaining_indices] if len(remaining_pts) < min_points: break # RANSAC search best_count = 0 best_inliers = None for _ in range(n_iterations): if len(remaining_pts) < 3: break idx = np.random.choice(len(remaining_pts), 3, replace=False) p1, p2, p3 = remaining_pts[idx] normal = np.cross(p2 - p1, p3 - p1) mag = np.linalg.norm(normal) if mag < 1e-8: continue normal /= mag d = -np.dot(normal, p1) distances = np.abs(remaining_pts @ normal + d) inliers = distances < distance_threshold count = inliers.sum() if count > best_count: best_count = count best_inliers = inliers if best_count < min_points: break # SVD refinement inlier_pts = remaining_pts[best_inliers] centroid = inlier_pts.mean(axis=0) _, _, vh = np.linalg.svd(inlier_pts - centroid) refined_normal = vh[2] refined_d = -np.dot(refined_normal, centroid) # Re-select inliers with refined plane distances = np.abs(remaining_pts @ refined_normal + refined_d) final_inliers = distances < distance_threshold # Map back to global indices global_inlier_idx = remaining_indices[final_inliers] remaining_mask[global_inlier_idx] = False # Ensure normal points upward if refined_normal[2] < 0: refined_normal = -refined_normal refined_d = -refined_d # Plane metrics pitch_deg = float(np.degrees(np.arccos(np.clip(abs(refined_normal[2]), 0, 1)))) azimuth_deg = float(np.degrees(np.arctan2(-refined_normal[0], -refined_normal[1])) % 360) # Reject wall-like fits — roofs top out around 60° pitch (12/12 = 45°). # Anything steeper is almost certainly RANSAC finding a facade edge in the DSM. if pitch_deg > 65.0: # Still consume the inliers so we don't re-fit the same wall, # but don't emit this plane. continue planes.append({ "segment_id": len(planes) + 1, "equation": [float(refined_normal[0]), float(refined_normal[1]), float(refined_normal[2]), float(refined_d)], "normal": refined_normal.tolist(), "plane_d": float(refined_d), "inlier_indices": global_inlier_idx, "pitch_deg": pitch_deg, "azimuth_deg": azimuth_deg, "mean_height": float(points_3d[global_inlier_idx, 2].mean()), "num_points": int(len(global_inlier_idx)), }) return planes def planes_to_label_map( planes: list[dict], valid_flat_indices: np.ndarray, img_shape: tuple[int, int], building_mask: np.ndarray, ) -> np.ndarray: """Convert RANSAC plane results to a pixel label map. Assigns each building pixel to its plane. Unlabeled pixels are filled via nearest-neighbor distance transform. Returns: Label map (H, W) int32. 0 = background. """ h, w = img_shape segments = np.zeros((h, w), dtype=np.int32) for plane in planes: global_idx = plane["inlier_indices"] flat_idx = valid_flat_indices[global_idx] py = flat_idx // w px = flat_idx % w segments[py, px] = plane["segment_id"] # Resize building mask if needed if building_mask.shape != (h, w): building_mask = cv2.resize( building_mask.astype(np.uint8), (w, h), interpolation=cv2.INTER_NEAREST, ) # Fill gaps within building via nearest labeled pixel valid = building_mask > 0 unlabeled = (segments == 0) & valid if unlabeled.any() and (segments > 0).any(): _, nearest_idx = distance_transform_edt(unlabeled, return_indices=True) segments[unlabeled] = segments[nearest_idx[0][unlabeled], nearest_idx[1][unlabeled]] return segments def build_plane_info(planes: list[dict], pixel_size: float = PIXEL_SIZE_M) -> list[dict]: """Build clean plane info list (without large inlier arrays). Returns list of dicts suitable for GeoJSON properties and display. """ return [ { "segment_id": p["segment_id"], "mean_slope": p["pitch_deg"], "mean_aspect": p["azimuth_deg"], "mean_height": p["mean_height"], "area_pixels": p["num_points"], "area_sqm": float(p["num_points"] * pixel_size ** 2), "plane_normal": p["normal"], "plane_d": p["plane_d"], } for p in planes ] def upscale_dsm_with_planes( dsm: np.ndarray, planes: list[dict], label_map: np.ndarray, factor: int = 4, pixel_size: float = PIXEL_SIZE_M, ) -> np.ndarray: """Re-rasterize DSM at higher resolution using fitted plane equations. For each pixel assigned to a plane, compute the exact height from z = -(Ax + By + D) / C. Unassigned pixels get bilinear interpolation. Returns: High-resolution DSM array (H*factor, W*factor). """ h, w = dsm.shape h_hr, w_hr = h * factor, w * factor # Build lookup: segment_id -> plane equation eq_lookup = {} for p in planes: eq_lookup[p["segment_id"]] = p["equation"] # High-res pixel grid in meters yy_hr, xx_hr = np.mgrid[0:h_hr, 0:w_hr] xx_m = xx_hr * (pixel_size / factor) yy_m = yy_hr * (pixel_size / factor) # Upscale label map to high-res label_hr = cv2.resize(label_map.astype(np.float32), (w_hr, h_hr), interpolation=cv2.INTER_NEAREST).astype(np.int32) dsm_hr = np.full((h_hr, w_hr), np.nan, dtype=np.float32) for seg_id, (a, b, c, d) in eq_lookup.items(): if abs(c) < 1e-10: continue mask = label_hr == seg_id dsm_hr[mask] = -(a * xx_m[mask] + b * yy_m[mask] + d) / c # Fill remaining pixels with bilinear interpolation of original from scipy.interpolate import RegularGridInterpolator unfilled = np.isnan(dsm_hr) if unfilled.any(): row_coords = np.arange(h) col_coords = np.arange(w) interp = RegularGridInterpolator( (row_coords, col_coords), dsm.astype(np.float32), method="linear", bounds_error=False, fill_value=np.nan, ) lr_rows = yy_hr[unfilled] / factor lr_cols = xx_hr[unfilled] / factor dsm_hr[unfilled] = interp(np.column_stack([lr_rows, lr_cols])) return dsm_hr