Spaces:
Sleeping
Sleeping
| """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) | |