Deagin commited on
Commit
eb2a5d2
Β·
1 Parent(s): 66bcd8c

DINOV3 roof segmentation with google solar api

Browse files
Files changed (4) hide show
  1. .gitignore +15 -0
  2. README.md +23 -4
  3. app.py +332 -102
  4. 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: other
11
  models:
12
  - facebook/dinov3-vitl16-pretrain-sat493m
13
  ---
14
 
15
  # Roof Segmentation with DINOv3 Satellite
16
 
17
- Segment roofs from satellite imagery using Meta's DINOv3 model pretrained on 493M satellite images.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- # Model selection - ViT-L for satellite imagery
12
- MODEL_NAME = "facebook/dinov3-vitl16-pretrain-sat493m"
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: 1 CLS + 4 register tokens + N patch tokens
28
- # Skip first 5 tokens (CLS + 4 registers)
29
  patch_features = outputs.last_hidden_state[:, 5:, :]
30
 
31
  return patch_features
32
 
33
- def segment_roof(image, num_segments=5, selected_clusters="0"):
34
- """
35
- Segment roofs using DINOv3 satellite features + K-means.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
- Args:
38
- image: Input satellite image
39
- num_segments: Number of K-means clusters
40
- selected_clusters: Comma-separated cluster indices to highlight as roof
41
- """
42
- if image is None:
43
- return None, None, "Please upload an image"
44
 
45
- # Convert to PIL if needed
46
- if isinstance(image, np.ndarray):
47
- image = Image.fromarray(image).convert("RGB")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
 
49
- original_size = image.size # (W, H)
 
 
 
 
 
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
- # Reshape for clustering
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
- # Color palette for visualization
82
- colors = np.array([
83
- [230, 25, 75], # Red
84
- [60, 180, 75], # Green
85
- [255, 225, 25], # Yellow
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
- # Create binary roof mask
105
- roof_mask = np.isin(seg_resized, roof_indices).astype(np.uint8) * 255
106
 
107
- # Create overlay visualization
108
- orig_array = np.array(image).astype(np.float32)
109
- overlay = orig_array * 0.4 + colored_seg.astype(np.float32) * 0.6
110
 
111
- # Highlight selected roof clusters
112
- for idx in roof_indices:
113
- mask = seg_resized == idx
114
- overlay[mask] = orig_array[mask] * 0.3 + np.array([255, 0, 0]) * 0.7
 
115
 
116
- # Calculate cluster statistics
117
- unique, counts = np.unique(seg_resized, return_counts=True)
118
- total_pixels = seg_resized.size
119
- stats = "**Cluster Statistics:**\n"
120
- for u, c in sorted(zip(unique, counts), key=lambda x: -x[1]):
121
- pct = (c / total_pixels) * 100
122
- marker = " ← ROOF" if u in roof_indices else ""
123
- stats += f"- Cluster {u}: {pct:.1f}%{marker}\n"
124
 
125
- return overlay.astype(np.uint8), roof_mask, stats
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
126
 
127
  # Gradio Interface
128
- with gr.Blocks(title="Roof Segmentation - DINOv3 Satellite", theme=gr.themes.Soft()) as demo:
129
  gr.Markdown("""
130
- # πŸ›°οΈ Roof Segmentation with DINOv3 (Satellite)
131
 
132
- Using Meta's **DINOv3 ViT-L** pretrained on **493M satellite images** at 0.6m resolution.
133
 
134
- Upload a satellite/aerial image to detect and segment roof areas.
135
  """)
136
 
137
  with gr.Row():
138
  with gr.Column(scale=1):
139
- input_image = gr.Image(type="pil", label="πŸ“Έ Upload Satellite Image")
 
 
 
 
 
 
 
 
 
 
 
140
 
141
- with gr.Accordion("βš™οΈ Segmentation Settings", open=True):
142
- num_segments = gr.Slider(
143
- minimum=3, maximum=12, value=5, step=1,
144
- label="Number of Segments",
145
- info="More segments = finer detail"
146
  )
 
147
  selected_clusters = gr.Textbox(
148
  value="0",
149
  label="Roof Cluster(s)",
150
- info="Enter cluster numbers separated by commas (e.g., '0,2')",
151
- placeholder="0"
 
 
 
152
  )
153
 
154
- segment_btn = gr.Button("πŸ” Segment Roofs", variant="primary", size="lg")
155
 
156
  with gr.Column(scale=2):
157
  with gr.Row():
158
- output_overlay = gr.Image(label="Segmentation Overlay")
159
- output_mask = gr.Image(label="Roof Mask (Binary)")
160
 
161
- cluster_stats = gr.Markdown(label="Cluster Info")
 
 
 
 
 
 
 
 
 
 
 
 
 
162
 
163
- segment_btn.click(
164
- fn=segment_roof,
165
- inputs=[input_image, num_segments, selected_clusters],
166
- outputs=[output_overlay, output_mask, cluster_stats]
167
  )
168
 
169
  gr.Markdown("""
170
  ---
171
  ### How to Use
172
- 1. Upload a satellite or aerial image of buildings
173
- 2. Click **Segment Roofs** to analyze
174
- 3. Look at the colored overlay - each color is a different segment
175
- 4. Find which cluster number(s) correspond to roofs (shown in stats)
176
- 5. Enter those numbers in **Roof Cluster(s)** and re-run
177
- 6. Download the binary mask for your workflow
178
-
179
- ### Tips
180
- - **Roofs** often cluster together due to similar materials/colors
181
- - Try **5-7 segments** for typical suburban imagery
182
- - Multiple buildings? Select multiple clusters: `0,3,5`
183
 
184
  ---
185
- *Powered by [DINOv3](https://github.com/facebookresearch/dinov3) pretrained on SAT-493M*
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