RoofSegmentation2 / ransac_planes.py
Deagin's picture
Fix complex-roof segmentation: recover small planes, reject walls, handle solar
88b423e
Raw
History Blame Contribute Delete
9.75 kB
"""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