Spaces:
Runtime error
Runtime error
DINOV3 roof segmentation with google solar api
Browse files- .gitignore +15 -0
- README.md +23 -4
- app.py +332 -102
- requirements.txt +4 -1
.gitignore
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
__pycache__/
|
| 2 |
+
*.py[cod]
|
| 3 |
+
*$py.class
|
| 4 |
+
*.so
|
| 5 |
+
.Python
|
| 6 |
+
env/
|
| 7 |
+
venv/
|
| 8 |
+
.env
|
| 9 |
+
*.egg-info/
|
| 10 |
+
dist/
|
| 11 |
+
build/
|
| 12 |
+
.ipynb_checkpoints/
|
| 13 |
+
*.log
|
| 14 |
+
.DS_Store
|
| 15 |
+
Thumbs.db
|
README.md
CHANGED
|
@@ -1,17 +1,36 @@
|
|
| 1 |
---
|
| 2 |
title: Roof Segmentation DINOv3
|
| 3 |
-
emoji:
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
-
sdk_version: 4.44.0
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
-
license:
|
| 11 |
models:
|
| 12 |
- facebook/dinov3-vitl16-pretrain-sat493m
|
| 13 |
---
|
| 14 |
|
| 15 |
# Roof Segmentation with DINOv3 Satellite
|
| 16 |
|
| 17 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: Roof Segmentation DINOv3
|
| 3 |
+
emoji: π
|
| 4 |
colorFrom: blue
|
| 5 |
colorTo: green
|
| 6 |
sdk: gradio
|
| 7 |
+
sdk_version: "4.44.0"
|
| 8 |
app_file: app.py
|
| 9 |
pinned: false
|
| 10 |
+
license: apache-2.0
|
| 11 |
models:
|
| 12 |
- facebook/dinov3-vitl16-pretrain-sat493m
|
| 13 |
---
|
| 14 |
|
| 15 |
# Roof Segmentation with DINOv3 Satellite
|
| 16 |
|
| 17 |
+
Extract roof polygons from satellite imagery using Meta's DINOv3 model pretrained on 493M satellite images.
|
| 18 |
+
|
| 19 |
+
## Features
|
| 20 |
+
|
| 21 |
+
- **Address Input** - Enter any US address
|
| 22 |
+
- **Google Solar API** - Fetches high-resolution GeoTIFF imagery
|
| 23 |
+
- **DINOv3 Segmentation** - State-of-the-art satellite image understanding
|
| 24 |
+
- **GeoJSON Output** - Real-world coordinates for each roof polygon
|
| 25 |
+
|
| 26 |
+
## Usage
|
| 27 |
+
|
| 28 |
+
1. Enter a property address
|
| 29 |
+
2. Click "Extract Roof Polygons"
|
| 30 |
+
3. Identify which color clusters represent roofs
|
| 31 |
+
4. Adjust cluster selection and re-run if needed
|
| 32 |
+
5. Download the GeoJSON file
|
| 33 |
+
|
| 34 |
+
## Requirements
|
| 35 |
+
|
| 36 |
+
- Google Cloud API key with Solar API and Geocoding API enabled
|
app.py
CHANGED
|
@@ -1,16 +1,25 @@
|
|
| 1 |
import gradio as gr
|
| 2 |
import torch
|
| 3 |
-
import torch.nn.functional as F
|
| 4 |
import numpy as np
|
| 5 |
from PIL import Image
|
| 6 |
from transformers import AutoImageProcessor, AutoModel
|
| 7 |
from sklearn.cluster import KMeans
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
import warnings
|
| 9 |
warnings.filterwarnings("ignore")
|
| 10 |
|
| 11 |
-
#
|
| 12 |
-
|
| 13 |
|
|
|
|
|
|
|
| 14 |
print(f"Loading {MODEL_NAME}...")
|
| 15 |
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 16 |
processor = AutoImageProcessor.from_pretrained(MODEL_NAME)
|
|
@@ -18,171 +27,392 @@ model = AutoModel.from_pretrained(MODEL_NAME).to(device)
|
|
| 18 |
model.eval()
|
| 19 |
print(f"Model loaded on {device}")
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def extract_features(image):
|
| 22 |
"""Extract dense patch features from DINOv3."""
|
| 23 |
inputs = processor(images=image, return_tensors="pt").to(device)
|
| 24 |
|
| 25 |
with torch.inference_mode():
|
| 26 |
outputs = model(**inputs)
|
| 27 |
-
# DINOv3:
|
| 28 |
-
# Skip first 5 tokens (CLS + 4 registers)
|
| 29 |
patch_features = outputs.last_hidden_state[:, 5:, :]
|
| 30 |
|
| 31 |
return patch_features
|
| 32 |
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 36 |
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
if image is None:
|
| 43 |
-
return None, None, "Please upload an image"
|
| 44 |
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
|
| 49 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
|
| 51 |
-
# Extract DINOv3 features
|
| 52 |
features = extract_features(image)
|
| 53 |
|
| 54 |
-
# Calculate spatial dimensions
|
| 55 |
-
# DINOv3 uses patch_size=16
|
| 56 |
num_patches = features.shape[1]
|
| 57 |
h = w = int(np.sqrt(num_patches))
|
| 58 |
|
| 59 |
-
|
| 60 |
-
feat_np = features.squeeze(0).cpu().numpy() # [num_patches, hidden_dim]
|
| 61 |
|
| 62 |
-
# PCA for dimensionality reduction (helps clustering)
|
| 63 |
-
from sklearn.decomposition import PCA
|
| 64 |
pca = PCA(n_components=64, random_state=42)
|
| 65 |
feat_reduced = pca.fit_transform(feat_np)
|
| 66 |
|
| 67 |
-
# K-means clustering
|
| 68 |
kmeans = KMeans(n_clusters=num_segments, random_state=42, n_init=10)
|
| 69 |
cluster_labels = kmeans.fit_predict(feat_reduced)
|
| 70 |
|
| 71 |
-
# Reshape to spatial grid
|
| 72 |
seg_map = cluster_labels.reshape(h, w)
|
| 73 |
-
|
| 74 |
-
# Upscale to original image size
|
| 75 |
seg_resized = np.array(
|
| 76 |
Image.fromarray(seg_map.astype(np.uint8)).resize(
|
| 77 |
original_size, resample=Image.NEAREST
|
| 78 |
)
|
| 79 |
)
|
| 80 |
|
| 81 |
-
|
| 82 |
-
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
[0, 130, 200], # Blue
|
| 87 |
-
[245, 130, 48], # Orange
|
| 88 |
-
[145, 30, 180], # Purple
|
| 89 |
-
[70, 240, 240], # Cyan
|
| 90 |
-
[240, 50, 230], # Magenta
|
| 91 |
-
[210, 245, 60], # Lime
|
| 92 |
-
[250, 190, 212], # Pink
|
| 93 |
-
])
|
| 94 |
-
|
| 95 |
-
# Create colored segmentation
|
| 96 |
-
colored_seg = colors[seg_resized % len(colors)]
|
| 97 |
-
|
| 98 |
-
# Parse selected clusters for roof mask
|
| 99 |
-
try:
|
| 100 |
-
roof_indices = [int(x.strip()) for x in selected_clusters.split(",") if x.strip()]
|
| 101 |
-
except:
|
| 102 |
-
roof_indices = [0]
|
| 103 |
|
| 104 |
-
|
| 105 |
-
roof_mask = np.isin(seg_resized, roof_indices).astype(np.uint8) * 255
|
| 106 |
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
overlay = orig_array * 0.4 + colored_seg.astype(np.float32) * 0.6
|
| 110 |
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
|
|
|
| 115 |
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
|
| 125 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
|
| 127 |
# Gradio Interface
|
| 128 |
-
with gr.Blocks(title="Roof Segmentation -
|
| 129 |
gr.Markdown("""
|
| 130 |
-
#
|
| 131 |
|
| 132 |
-
|
| 133 |
|
| 134 |
-
|
| 135 |
""")
|
| 136 |
|
| 137 |
with gr.Row():
|
| 138 |
with gr.Column(scale=1):
|
| 139 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
|
| 141 |
-
with gr.Accordion("βοΈ
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
label="
|
| 145 |
-
info="
|
| 146 |
)
|
|
|
|
| 147 |
selected_clusters = gr.Textbox(
|
| 148 |
value="0",
|
| 149 |
label="Roof Cluster(s)",
|
| 150 |
-
|
| 151 |
-
|
|
|
|
|
|
|
|
|
|
| 152 |
)
|
| 153 |
|
| 154 |
-
|
| 155 |
|
| 156 |
with gr.Column(scale=2):
|
| 157 |
with gr.Row():
|
| 158 |
-
|
| 159 |
-
|
| 160 |
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
-
|
| 164 |
-
fn=
|
| 165 |
-
inputs=[
|
| 166 |
-
outputs=[
|
| 167 |
)
|
| 168 |
|
| 169 |
gr.Markdown("""
|
| 170 |
---
|
| 171 |
### How to Use
|
| 172 |
-
1.
|
| 173 |
-
2. Click **
|
| 174 |
-
3.
|
| 175 |
-
4.
|
| 176 |
-
5.
|
| 177 |
-
|
| 178 |
-
|
| 179 |
-
|
| 180 |
-
-
|
| 181 |
-
- Try **5-7 segments** for typical suburban imagery
|
| 182 |
-
- Multiple buildings? Select multiple clusters: `0,3,5`
|
| 183 |
|
| 184 |
---
|
| 185 |
-
*Powered by
|
| 186 |
""")
|
| 187 |
|
| 188 |
demo.launch()
|
|
|
|
| 1 |
import gradio as gr
|
| 2 |
import torch
|
|
|
|
| 3 |
import numpy as np
|
| 4 |
from PIL import Image
|
| 5 |
from transformers import AutoImageProcessor, AutoModel
|
| 6 |
from sklearn.cluster import KMeans
|
| 7 |
+
from sklearn.decomposition import PCA
|
| 8 |
+
import cv2
|
| 9 |
+
import json
|
| 10 |
+
import requests
|
| 11 |
+
import io
|
| 12 |
+
import os
|
| 13 |
+
import rasterio
|
| 14 |
+
from rasterio.crs import CRS
|
| 15 |
import warnings
|
| 16 |
warnings.filterwarnings("ignore")
|
| 17 |
|
| 18 |
+
# Load API key from environment (set as HF Space secret)
|
| 19 |
+
GOOGLE_API_KEY = os.environ.get("GOOGLE_API_KEY", "")
|
| 20 |
|
| 21 |
+
# DINOv3 Model - Satellite pretrained
|
| 22 |
+
MODEL_NAME = "facebook/dinov3-vitl16-pretrain-sat493m"
|
| 23 |
print(f"Loading {MODEL_NAME}...")
|
| 24 |
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 25 |
processor = AutoImageProcessor.from_pretrained(MODEL_NAME)
|
|
|
|
| 27 |
model.eval()
|
| 28 |
print(f"Model loaded on {device}")
|
| 29 |
|
| 30 |
+
|
| 31 |
+
def geocode_address(address, api_key):
|
| 32 |
+
"""Convert address to lat/lng using Google Geocoding API."""
|
| 33 |
+
url = "https://maps.googleapis.com/maps/api/geocode/json"
|
| 34 |
+
params = {
|
| 35 |
+
"address": address,
|
| 36 |
+
"key": api_key
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
response = requests.get(url, params=params)
|
| 40 |
+
data = response.json()
|
| 41 |
+
|
| 42 |
+
if data["status"] != "OK":
|
| 43 |
+
raise ValueError(f"Geocoding failed: {data['status']}")
|
| 44 |
+
|
| 45 |
+
location = data["results"][0]["geometry"]["location"]
|
| 46 |
+
formatted_address = data["results"][0]["formatted_address"]
|
| 47 |
+
|
| 48 |
+
return location["lat"], location["lng"], formatted_address
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def fetch_geotiff(lat, lng, api_key, radius_meters=50):
|
| 52 |
+
"""Fetch RGB GeoTIFF from Google Solar API Data Layers."""
|
| 53 |
+
|
| 54 |
+
layers_url = "https://solar.googleapis.com/v1/dataLayers:get"
|
| 55 |
+
params = {
|
| 56 |
+
"location.latitude": lat,
|
| 57 |
+
"location.longitude": lng,
|
| 58 |
+
"radiusMeters": radius_meters,
|
| 59 |
+
"view": "FULL_LAYERS",
|
| 60 |
+
"requiredQuality": "HIGH",
|
| 61 |
+
"pixelSizeMeters": 0.25,
|
| 62 |
+
"key": api_key
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
response = requests.get(layers_url, params=params)
|
| 66 |
+
|
| 67 |
+
if response.status_code != 200:
|
| 68 |
+
params["requiredQuality"] = "MEDIUM"
|
| 69 |
+
response = requests.get(layers_url, params=params)
|
| 70 |
+
|
| 71 |
+
if response.status_code != 200:
|
| 72 |
+
raise ValueError(f"Data Layers API error: {response.status_code} - {response.text}")
|
| 73 |
+
|
| 74 |
+
layers = response.json()
|
| 75 |
+
|
| 76 |
+
rgb_url = layers.get("rgbUrl")
|
| 77 |
+
if not rgb_url:
|
| 78 |
+
raise ValueError("No RGB imagery available for this location")
|
| 79 |
+
|
| 80 |
+
rgb_response = requests.get(f"{rgb_url}&key={api_key}")
|
| 81 |
+
if rgb_response.status_code != 200:
|
| 82 |
+
raise ValueError(f"Failed to download GeoTIFF: {rgb_response.status_code}")
|
| 83 |
+
|
| 84 |
+
return rgb_response.content, layers
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def parse_geotiff(geotiff_bytes):
|
| 88 |
+
"""Parse GeoTIFF and extract image + bounds."""
|
| 89 |
+
|
| 90 |
+
with rasterio.open(io.BytesIO(geotiff_bytes)) as src:
|
| 91 |
+
if src.count >= 3:
|
| 92 |
+
r = src.read(1)
|
| 93 |
+
g = src.read(2)
|
| 94 |
+
b = src.read(3)
|
| 95 |
+
img_array = np.stack([r, g, b], axis=-1)
|
| 96 |
+
else:
|
| 97 |
+
img_array = src.read(1)
|
| 98 |
+
img_array = np.stack([img_array] * 3, axis=-1)
|
| 99 |
+
|
| 100 |
+
bounds = src.bounds
|
| 101 |
+
crs = src.crs
|
| 102 |
+
|
| 103 |
+
if crs and crs != CRS.from_epsg(4326):
|
| 104 |
+
from rasterio.warp import transform_bounds
|
| 105 |
+
bounds = transform_bounds(crs, CRS.from_epsg(4326), *bounds)
|
| 106 |
+
|
| 107 |
+
image = Image.fromarray(img_array.astype(np.uint8))
|
| 108 |
+
return image, bounds
|
| 109 |
+
|
| 110 |
+
|
| 111 |
def extract_features(image):
|
| 112 |
"""Extract dense patch features from DINOv3."""
|
| 113 |
inputs = processor(images=image, return_tensors="pt").to(device)
|
| 114 |
|
| 115 |
with torch.inference_mode():
|
| 116 |
outputs = model(**inputs)
|
| 117 |
+
# DINOv3: skip CLS + 4 register tokens
|
|
|
|
| 118 |
patch_features = outputs.last_hidden_state[:, 5:, :]
|
| 119 |
|
| 120 |
return patch_features
|
| 121 |
|
| 122 |
+
|
| 123 |
+
def pixel_to_geo(x, y, img_width, img_height, bounds):
|
| 124 |
+
"""Convert pixel coordinates to geographic coordinates."""
|
| 125 |
+
west, south, east, north = bounds
|
| 126 |
+
|
| 127 |
+
x_norm = x / img_width
|
| 128 |
+
y_norm = y / img_height
|
| 129 |
+
|
| 130 |
+
lng = west + (east - west) * x_norm
|
| 131 |
+
lat = north - (north - south) * y_norm
|
| 132 |
+
|
| 133 |
+
return [lng, lat]
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def mask_to_polygons(mask, bounds, img_width, img_height):
|
| 137 |
+
"""Convert binary mask to GeoJSON polygons."""
|
| 138 |
+
features = []
|
| 139 |
|
| 140 |
+
contours, _ = cv2.findContours(
|
| 141 |
+
mask.astype(np.uint8),
|
| 142 |
+
cv2.RETR_EXTERNAL,
|
| 143 |
+
cv2.CHAIN_APPROX_SIMPLE
|
| 144 |
+
)
|
|
|
|
|
|
|
| 145 |
|
| 146 |
+
for i, contour in enumerate(contours):
|
| 147 |
+
area = cv2.contourArea(contour)
|
| 148 |
+
if area < 100:
|
| 149 |
+
continue
|
| 150 |
+
|
| 151 |
+
epsilon = 0.015 * cv2.arcLength(contour, True)
|
| 152 |
+
simplified = cv2.approxPolyDP(contour, epsilon, True)
|
| 153 |
+
|
| 154 |
+
coords = []
|
| 155 |
+
for point in simplified:
|
| 156 |
+
px, py = point[0]
|
| 157 |
+
geo_coord = pixel_to_geo(px, py, img_width, img_height, bounds)
|
| 158 |
+
coords.append(geo_coord)
|
| 159 |
+
|
| 160 |
+
if coords and coords[0] != coords[-1]:
|
| 161 |
+
coords.append(coords[0])
|
| 162 |
+
|
| 163 |
+
if len(coords) >= 4:
|
| 164 |
+
west, south, east, north = bounds
|
| 165 |
+
meters_per_lng = 111320 * np.cos(np.radians((north + south) / 2))
|
| 166 |
+
meters_per_lat = 111320
|
| 167 |
+
pixel_width_m = (east - west) * meters_per_lng / img_width
|
| 168 |
+
pixel_height_m = (north - south) * meters_per_lat / img_height
|
| 169 |
+
area_sqm = area * pixel_width_m * pixel_height_m
|
| 170 |
+
|
| 171 |
+
feature = {
|
| 172 |
+
"type": "Feature",
|
| 173 |
+
"properties": {
|
| 174 |
+
"roof_id": i + 1,
|
| 175 |
+
"area_sqm": round(area_sqm, 2),
|
| 176 |
+
"area_sqft": round(area_sqm * 10.764, 2),
|
| 177 |
+
"num_vertices": len(coords) - 1
|
| 178 |
+
},
|
| 179 |
+
"geometry": {
|
| 180 |
+
"type": "Polygon",
|
| 181 |
+
"coordinates": [coords]
|
| 182 |
+
}
|
| 183 |
+
}
|
| 184 |
+
features.append(feature)
|
| 185 |
|
| 186 |
+
return features
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
def segment_image(image, num_segments):
|
| 190 |
+
"""Run DINOv3 segmentation on image."""
|
| 191 |
+
original_size = image.size
|
| 192 |
|
|
|
|
| 193 |
features = extract_features(image)
|
| 194 |
|
|
|
|
|
|
|
| 195 |
num_patches = features.shape[1]
|
| 196 |
h = w = int(np.sqrt(num_patches))
|
| 197 |
|
| 198 |
+
feat_np = features.squeeze(0).cpu().numpy()
|
|
|
|
| 199 |
|
|
|
|
|
|
|
| 200 |
pca = PCA(n_components=64, random_state=42)
|
| 201 |
feat_reduced = pca.fit_transform(feat_np)
|
| 202 |
|
|
|
|
| 203 |
kmeans = KMeans(n_clusters=num_segments, random_state=42, n_init=10)
|
| 204 |
cluster_labels = kmeans.fit_predict(feat_reduced)
|
| 205 |
|
|
|
|
| 206 |
seg_map = cluster_labels.reshape(h, w)
|
|
|
|
|
|
|
| 207 |
seg_resized = np.array(
|
| 208 |
Image.fromarray(seg_map.astype(np.uint8)).resize(
|
| 209 |
original_size, resample=Image.NEAREST
|
| 210 |
)
|
| 211 |
)
|
| 212 |
|
| 213 |
+
return seg_resized
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def process_address(address, num_segments, selected_clusters, min_area, radius_meters, api_key_input):
|
| 217 |
+
"""Main pipeline: address -> GeoJSON polygons."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
|
| 219 |
+
api_key = api_key_input.strip() if api_key_input.strip() else GOOGLE_API_KEY
|
|
|
|
| 220 |
|
| 221 |
+
if not api_key:
|
| 222 |
+
return None, None, None, None, "β No API key provided. Enter your Google Solar API key."
|
|
|
|
| 223 |
|
| 224 |
+
try:
|
| 225 |
+
lat, lng, formatted_address = geocode_address(address, api_key)
|
| 226 |
+
status = f"π **{formatted_address}**\n\nCoordinates: {lat:.6f}, {lng:.6f}\n\n"
|
| 227 |
+
except Exception as e:
|
| 228 |
+
return None, None, None, None, f"β Geocoding failed: {str(e)}"
|
| 229 |
|
| 230 |
+
try:
|
| 231 |
+
status += "Fetching satellite imagery...\n"
|
| 232 |
+
geotiff_bytes, layers_info = fetch_geotiff(lat, lng, api_key, radius_meters)
|
| 233 |
+
image, bounds = parse_geotiff(geotiff_bytes)
|
| 234 |
+
img_width, img_height = image.size
|
| 235 |
+
status += f"Image size: {img_width}x{img_height}px\n\n"
|
| 236 |
+
except Exception as e:
|
| 237 |
+
return None, None, None, None, f"β Failed to fetch imagery: {str(e)}"
|
| 238 |
|
| 239 |
+
try:
|
| 240 |
+
seg_resized = segment_image(image, num_segments)
|
| 241 |
+
|
| 242 |
+
colors = np.array([
|
| 243 |
+
[230, 25, 75], [60, 180, 75], [255, 225, 25], [0, 130, 200],
|
| 244 |
+
[245, 130, 48], [145, 30, 180], [70, 240, 240], [240, 50, 230],
|
| 245 |
+
[210, 245, 60], [250, 190, 212], [128, 128, 0], [0, 128, 128]
|
| 246 |
+
])
|
| 247 |
+
|
| 248 |
+
colored_seg = colors[seg_resized % len(colors)]
|
| 249 |
+
|
| 250 |
+
try:
|
| 251 |
+
roof_indices = [int(x.strip()) for x in selected_clusters.split(",") if x.strip()]
|
| 252 |
+
except:
|
| 253 |
+
roof_indices = [0]
|
| 254 |
+
|
| 255 |
+
roof_mask = np.isin(seg_resized, roof_indices).astype(np.uint8) * 255
|
| 256 |
+
|
| 257 |
+
kernel = np.ones((5, 5), np.uint8)
|
| 258 |
+
roof_mask = cv2.morphologyEx(roof_mask, cv2.MORPH_CLOSE, kernel)
|
| 259 |
+
roof_mask = cv2.morphologyEx(roof_mask, cv2.MORPH_OPEN, kernel)
|
| 260 |
+
|
| 261 |
+
polygon_features = mask_to_polygons(roof_mask, bounds, img_width, img_height)
|
| 262 |
+
polygon_features = [f for f in polygon_features if f["properties"]["area_sqft"] >= min_area]
|
| 263 |
+
|
| 264 |
+
geojson = {
|
| 265 |
+
"type": "FeatureCollection",
|
| 266 |
+
"properties": {
|
| 267 |
+
"source": "DINOv3 Roof Segmentation",
|
| 268 |
+
"address": formatted_address,
|
| 269 |
+
"center": {"lat": lat, "lng": lng},
|
| 270 |
+
"bounds": {
|
| 271 |
+
"north": bounds[3], "south": bounds[1],
|
| 272 |
+
"east": bounds[2], "west": bounds[0]
|
| 273 |
+
}
|
| 274 |
+
},
|
| 275 |
+
"features": polygon_features
|
| 276 |
+
}
|
| 277 |
+
|
| 278 |
+
geojson_str = json.dumps(geojson, indent=2)
|
| 279 |
+
|
| 280 |
+
orig_array = np.array(image).astype(np.float32)
|
| 281 |
+
overlay = orig_array * 0.4 + colored_seg.astype(np.float32) * 0.6
|
| 282 |
+
|
| 283 |
+
for feature in polygon_features:
|
| 284 |
+
coords = feature["geometry"]["coordinates"][0]
|
| 285 |
+
pixel_coords = []
|
| 286 |
+
for lnglat in coords:
|
| 287 |
+
px = int((lnglat[0] - bounds[0]) / (bounds[2] - bounds[0]) * img_width)
|
| 288 |
+
py = int((bounds[3] - lnglat[1]) / (bounds[3] - bounds[1]) * img_height)
|
| 289 |
+
pixel_coords.append([px, py])
|
| 290 |
+
|
| 291 |
+
pts = np.array(pixel_coords, dtype=np.int32)
|
| 292 |
+
cv2.polylines(overlay, [pts], True, (255, 255, 0), 3)
|
| 293 |
+
|
| 294 |
+
for idx in roof_indices:
|
| 295 |
+
mask_highlight = seg_resized == idx
|
| 296 |
+
overlay[mask_highlight] = orig_array[mask_highlight] * 0.3 + np.array([255, 50, 50]) * 0.7
|
| 297 |
+
|
| 298 |
+
total_sqft = sum(f["properties"]["area_sqft"] for f in polygon_features)
|
| 299 |
+
status += f"**Found {len(polygon_features)} roof polygon(s)**\n"
|
| 300 |
+
status += f"**Total roof area: {total_sqft:,.0f} sq ft**\n\n"
|
| 301 |
+
|
| 302 |
+
for f in polygon_features:
|
| 303 |
+
props = f["properties"]
|
| 304 |
+
status += f"- Roof {props['roof_id']}: {props['area_sqft']:,.0f} sq ft\n"
|
| 305 |
+
|
| 306 |
+
status += "\n**Cluster Distribution:**\n"
|
| 307 |
+
unique, counts = np.unique(seg_resized, return_counts=True)
|
| 308 |
+
total = seg_resized.size
|
| 309 |
+
for u, c in sorted(zip(unique, counts), key=lambda x: -x[1]):
|
| 310 |
+
pct = (c / total) * 100
|
| 311 |
+
marker = " β ROOF" if u in roof_indices else ""
|
| 312 |
+
status += f"- Cluster {u}: {pct:.1f}%{marker}\n"
|
| 313 |
+
|
| 314 |
+
return np.array(image), overlay.astype(np.uint8), roof_mask, geojson_str, status
|
| 315 |
+
|
| 316 |
+
except Exception as e:
|
| 317 |
+
import traceback
|
| 318 |
+
return None, None, None, None, f"β Segmentation failed: {str(e)}\n\n{traceback.format_exc()}"
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
def save_geojson(geojson_str):
|
| 322 |
+
"""Save GeoJSON for download."""
|
| 323 |
+
if not geojson_str:
|
| 324 |
+
return None
|
| 325 |
+
filepath = "/tmp/roof_polygons.geojson"
|
| 326 |
+
with open(filepath, "w") as f:
|
| 327 |
+
f.write(geojson_str)
|
| 328 |
+
return filepath
|
| 329 |
+
|
| 330 |
|
| 331 |
# Gradio Interface
|
| 332 |
+
with gr.Blocks(title="Roof Segmentation - Address to GeoJSON", theme=gr.themes.Soft()) as demo:
|
| 333 |
gr.Markdown("""
|
| 334 |
+
# π Address β Roof Polygons (GeoJSON)
|
| 335 |
|
| 336 |
+
Enter an address, get roof segment polygons with real-world coordinates.
|
| 337 |
|
| 338 |
+
**Pipeline:** Address β Google Solar API (GeoTIFF) β DINOv3 Segmentation β GeoJSON
|
| 339 |
""")
|
| 340 |
|
| 341 |
with gr.Row():
|
| 342 |
with gr.Column(scale=1):
|
| 343 |
+
address_input = gr.Textbox(
|
| 344 |
+
label="π Property Address",
|
| 345 |
+
placeholder="123 Main St, Sacramento, CA",
|
| 346 |
+
lines=2
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
with gr.Accordion("π API Key", open=False):
|
| 350 |
+
api_key_input = gr.Textbox(
|
| 351 |
+
label="Google Solar API Key",
|
| 352 |
+
placeholder="Enter API key (or set GOOGLE_API_KEY secret)",
|
| 353 |
+
type="password"
|
| 354 |
+
)
|
| 355 |
|
| 356 |
+
with gr.Accordion("βοΈ Settings", open=True):
|
| 357 |
+
radius_meters = gr.Slider(
|
| 358 |
+
25, 100, value=50, step=5,
|
| 359 |
+
label="Image Radius (meters)",
|
| 360 |
+
info="Area around the address to capture"
|
| 361 |
)
|
| 362 |
+
num_segments = gr.Slider(3, 12, value=6, step=1, label="Segments")
|
| 363 |
selected_clusters = gr.Textbox(
|
| 364 |
value="0",
|
| 365 |
label="Roof Cluster(s)",
|
| 366 |
+
placeholder="0,2,5"
|
| 367 |
+
)
|
| 368 |
+
min_area = gr.Slider(
|
| 369 |
+
50, 2000, value=200, step=50,
|
| 370 |
+
label="Min Roof Area (sq ft)"
|
| 371 |
)
|
| 372 |
|
| 373 |
+
process_btn = gr.Button("π Extract Roof Polygons", variant="primary", size="lg")
|
| 374 |
|
| 375 |
with gr.Column(scale=2):
|
| 376 |
with gr.Row():
|
| 377 |
+
original_img = gr.Image(label="Satellite Image")
|
| 378 |
+
overlay_img = gr.Image(label="Segmentation + Polygons")
|
| 379 |
|
| 380 |
+
with gr.Row():
|
| 381 |
+
mask_img = gr.Image(label="Roof Mask")
|
| 382 |
+
status_output = gr.Markdown()
|
| 383 |
+
|
| 384 |
+
with gr.Accordion("π GeoJSON Output", open=True):
|
| 385 |
+
geojson_output = gr.Code(language="json", lines=12)
|
| 386 |
+
download_btn = gr.Button("β¬οΈ Download GeoJSON")
|
| 387 |
+
download_file = gr.File(label="Download")
|
| 388 |
+
|
| 389 |
+
process_btn.click(
|
| 390 |
+
fn=process_address,
|
| 391 |
+
inputs=[address_input, num_segments, selected_clusters, min_area, radius_meters, api_key_input],
|
| 392 |
+
outputs=[original_img, overlay_img, mask_img, geojson_output, status_output]
|
| 393 |
+
)
|
| 394 |
|
| 395 |
+
download_btn.click(
|
| 396 |
+
fn=save_geojson,
|
| 397 |
+
inputs=[geojson_output],
|
| 398 |
+
outputs=[download_file]
|
| 399 |
)
|
| 400 |
|
| 401 |
gr.Markdown("""
|
| 402 |
---
|
| 403 |
### How to Use
|
| 404 |
+
1. Enter a US property address
|
| 405 |
+
2. Click **Extract Roof Polygons**
|
| 406 |
+
3. Review the segmentation - identify which cluster colors are roofs
|
| 407 |
+
4. Enter roof cluster numbers and re-run if needed
|
| 408 |
+
5. Download GeoJSON for your workflow
|
| 409 |
+
|
| 410 |
+
### Requirements
|
| 411 |
+
- Google Cloud project with **Solar API** and **Geocoding API** enabled
|
| 412 |
+
- API key with access to both APIs
|
|
|
|
|
|
|
| 413 |
|
| 414 |
---
|
| 415 |
+
*Powered by DINOv3 (SAT-493M) + Google Solar API*
|
| 416 |
""")
|
| 417 |
|
| 418 |
demo.launch()
|
requirements.txt
CHANGED
|
@@ -3,4 +3,7 @@ transformers>=4.40.0
|
|
| 3 |
gradio>=4.0.0
|
| 4 |
Pillow
|
| 5 |
numpy
|
| 6 |
-
scikit-learn
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
gradio>=4.0.0
|
| 4 |
Pillow
|
| 5 |
numpy
|
| 6 |
+
scikit-learn
|
| 7 |
+
opencv-python-headless
|
| 8 |
+
requests
|
| 9 |
+
rasterio
|