RoofSegmentation2 / geo_export.py
Deagin's picture
Tune segmentation: reduce over-splitting and clean polygon shapes
dda24a3
"""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,
}