RoofSegmentation2 / building.py
Deagin's picture
Rewrite: C-RADIOv4-H + RANSAC fusion pipeline
5c52fb9
"""Building isolation and cropping.
Selects the primary building from a multi-building mask using
geocoded coordinates, then crops imagery to the building footprint.
"""
import numpy as np
import cv2
from scipy.ndimage import label
from PIL import Image
def isolate_primary_building(
building_mask: np.ndarray,
center_lat: float,
center_lng: float,
bounds: tuple,
) -> np.ndarray | None:
"""Select the building closest to the geocoded address.
Uses connected component labeling to identify separate buildings,
then picks the one containing or nearest to the center coordinates.
Args:
building_mask: Binary mask (H, W) from Google Solar API.
center_lat: Latitude from geocoding.
center_lng: Longitude from geocoding.
bounds: (west, south, east, north) in WGS84.
Returns:
Binary mask of just the primary building, or None.
"""
if building_mask is None:
return None
labeled_mask, num_buildings = label(building_mask > 0)
if num_buildings == 0:
return None
if num_buildings == 1:
return (labeled_mask == 1).astype(np.uint8)
# Convert lat/lng to pixel coordinates
h, w = building_mask.shape
west, south, east, north = bounds
center_x = int((center_lng - west) / (east - west) * w)
center_y = int((north - center_lat) / (north - south) * h)
center_x = max(0, min(w - 1, center_x))
center_y = max(0, min(h - 1, center_y))
# Check if center is inside a building
center_building_id = labeled_mask[center_y, center_x]
if center_building_id > 0:
return (labeled_mask == center_building_id).astype(np.uint8)
# Find nearest building
min_dist = float("inf")
closest_id = 1
for building_id in range(1, num_buildings + 1):
pixels = np.argwhere(labeled_mask == building_id)
if len(pixels) == 0:
continue
distances = np.sqrt(
(pixels[:, 0] - center_y) ** 2 + (pixels[:, 1] - center_x) ** 2
)
d = distances.min()
if d < min_dist:
min_dist = d
closest_id = building_id
return (labeled_mask == closest_id).astype(np.uint8)
def crop_to_building(
image: np.ndarray | Image.Image,
building_mask: np.ndarray,
padding_pct: float = 0.1,
) -> tuple[np.ndarray, dict, np.ndarray]:
"""Crop image and mask to building bounding box with padding.
Args:
image: RGB image (np.ndarray or PIL Image).
building_mask: Binary mask (H, W).
padding_pct: Padding as fraction of building size.
Returns:
(cropped_image_array, crop_info, cropped_mask)
crop_info has keys: rmin, rmax, cmin, cmax, original_shape.
"""
if isinstance(image, Image.Image):
image = np.array(image)
if building_mask is None:
return image, None, None
# Resize mask to match image if needed
if building_mask.shape != image.shape[:2]:
building_mask = cv2.resize(
building_mask.astype(np.uint8),
(image.shape[1], image.shape[0]),
interpolation=cv2.INTER_NEAREST,
)
rows = np.any(building_mask > 0, axis=1)
cols = np.any(building_mask > 0, axis=0)
if not rows.any() or not cols.any():
return image, None, None
rmin, rmax = np.where(rows)[0][[0, -1]]
cmin, cmax = np.where(cols)[0][[0, -1]]
h, w = image.shape[:2]
pad_h = int((rmax - rmin) * padding_pct)
pad_w = int((cmax - cmin) * padding_pct)
rmin = max(0, rmin - pad_h)
rmax = min(h, rmax + pad_h)
cmin = max(0, cmin - pad_w)
cmax = min(w, cmax + pad_w)
crop_info = {
"rmin": rmin,
"rmax": rmax,
"cmin": cmin,
"cmax": cmax,
"original_shape": (h, w),
}
return image[rmin:rmax, cmin:cmax], crop_info, building_mask[rmin:rmax, cmin:cmax]
def recalculate_bounds(bounds: tuple, crop_info: dict) -> tuple:
"""Recalculate WGS84 bounds after cropping.
Args:
bounds: Original (west, south, east, north).
crop_info: From crop_to_building.
Returns:
New (west, south, east, north) for the cropped region.
"""
west, south, east, north = bounds
orig_h, orig_w = crop_info["original_shape"]
rmin, rmax = crop_info["rmin"], crop_info["rmax"]
cmin, cmax = crop_info["cmin"], crop_info["cmax"]
new_west = west + (east - west) * cmin / orig_w
new_east = west + (east - west) * cmax / orig_w
new_north = north - (north - south) * rmin / orig_h
new_south = north - (north - south) * rmax / orig_h
return (new_west, new_south, new_east, new_north)