"""GeoJSON polygon extraction and export. Converts pixel-space label maps to georeferenced GeoJSON polygons with area, pitch, and azimuth properties. """ import math import numpy as np import cv2 def pixel_to_geo( x: float, y: float, img_w: int, img_h: int, bounds: tuple ) -> list[float]: """Convert pixel coordinates to [longitude, latitude] (GeoJSON order). Y-axis is flipped: pixel y=0 is north, y=max is south. """ west, south, east, north = bounds lng = west + (east - west) * (x / img_w) lat = north - (north - south) * (y / img_h) return [lng, lat] def geo_to_pixel( lng: float, lat: float, img_w: int, img_h: int, bounds: tuple ) -> tuple[int, int]: """Convert geographic coordinates to pixel coordinates.""" west, south, east, north = bounds px = int((lng - west) / (east - west) * img_w) py = int((north - lat) / (north - south) * img_h) return px, py def _compute_area(contour_area_px: float, img_w: int, img_h: int, bounds: tuple) -> tuple[float, float]: """Compute area in sqm and sqft from pixel area.""" west, south, east, north = bounds center_lat = (north + south) / 2 meters_per_lng = 111320 * math.cos(math.radians(center_lat)) meters_per_lat = 111320 pixel_width_m = (east - west) * meters_per_lng / img_w pixel_height_m = (north - south) * meters_per_lat / img_h area_sqm = contour_area_px * pixel_width_m * pixel_height_m area_sqft = area_sqm * 10.764 return area_sqm, area_sqft def _is_boundary_rect(contour, img_w: int, img_h: int, tolerance: int = 5) -> bool: """Check if contour matches the full image bounds (false positive).""" epsilon = 0.02 * cv2.arcLength(contour, True) approx = cv2.approxPolyDP(contour, epsilon, True) if len(approx) != 4: return False x, y, w, h = cv2.boundingRect(contour) return ( x < tolerance and y < tolerance and (x + w) > (img_w - tolerance) and (y + h) > (img_h - tolerance) ) def labels_to_geojson( label_map: np.ndarray, bounds: tuple, plane_info: list[dict], min_area_sqft: float = 50.0, ) -> dict: """Convert a label map to a GeoJSON FeatureCollection. Args: label_map: Integer label map (H, W). 0 = background. bounds: (west, south, east, north) in WGS84. plane_info: List of dicts with plane metadata (keyed by segment_id). min_area_sqft: Minimum polygon area to include. Returns: GeoJSON FeatureCollection dict. """ h, w = label_map.shape features = [] # Build lookup from segment_id to plane info info_lookup = {p["segment_id"]: p for p in plane_info} unique_labels = sorted(set(np.unique(label_map)) - {0}) for label_id in unique_labels: mask = (label_map == label_id).astype(np.uint8) contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) for contour in contours: px_area = cv2.contourArea(contour) if px_area < 100: continue if _is_boundary_rect(contour, w, h): continue area_sqm, area_sqft = _compute_area(px_area, w, h, bounds) if area_sqft < min_area_sqft: continue # Simplify polygon (2.5% of perimeter — clean roof plane shapes) perimeter = cv2.arcLength(contour, True) epsilon = 0.025 * perimeter simplified = cv2.approxPolyDP(contour, epsilon, True) coords = [pixel_to_geo(pt[0][0], pt[0][1], w, h, bounds) for pt in simplified] # Close the ring if coords and coords[0] != coords[-1]: coords.append(coords[0]) if len(coords) < 4: continue # Merge plane properties props = { "roof_plane_id": int(label_id), "area_sqm": round(area_sqm, 2), "area_sqft": round(area_sqft, 2), "num_vertices": len(coords) - 1, } info = info_lookup.get(label_id, {}) if "mean_slope" in info: props["pitch_deg"] = round(info["mean_slope"], 1) if "mean_aspect" in info: props["azimuth_deg"] = round(info["mean_aspect"], 1) if "mean_height" in info: props["height_m"] = round(info["mean_height"], 2) features.append( { "type": "Feature", "properties": props, "geometry": {"type": "Polygon", "coordinates": [coords]}, } ) return { "type": "FeatureCollection", "features": features, }