jbilcke-hf HF Staff commited on
Commit
c8a43f4
·
verified ·
1 Parent(s): cf38158

Upload script.py

Browse files
Files changed (1) hide show
  1. script.py +362 -0
script.py ADDED
@@ -0,0 +1,362 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import cv2
3
+ import trimesh
4
+ import argparse
5
+ from PIL import Image
6
+ from sklearn.cluster import KMeans
7
+
8
+ class SatelliteModelGenerator:
9
+ def __init__(self, building_height=0.05):
10
+ self.building_height = building_height
11
+
12
+ # Reference colors for segmentation (RGB)
13
+ self.shadow_colors = np.array([
14
+ [31, 42, 76],
15
+ [58, 64, 92],
16
+ [15, 27, 56],
17
+ [21, 22, 50],
18
+ [76, 81, 99]
19
+ ])
20
+
21
+ self.road_colors = np.array([
22
+ [187, 182, 175],
23
+ [138, 138, 138],
24
+ [142, 142, 129],
25
+ [202, 199, 189]
26
+ ])
27
+
28
+ self.water_colors = np.array([
29
+ [167, 225, 217],
30
+ [67, 101, 97],
31
+ [53, 83, 84],
32
+ [47, 94, 100],
33
+ [73, 131, 135]
34
+ ])
35
+
36
+ # Convert and normalize reference colors to HSV
37
+ self.shadow_colors_hsv = cv2.cvtColor(self.shadow_colors.reshape(-1, 1, 3).astype(np.uint8),
38
+ cv2.COLOR_RGB2HSV).reshape(-1, 3).astype(float)
39
+ self.road_colors_hsv = cv2.cvtColor(self.road_colors.reshape(-1, 1, 3).astype(np.uint8),
40
+ cv2.COLOR_RGB2HSV).reshape(-1, 3).astype(float)
41
+ self.water_colors_hsv = cv2.cvtColor(self.water_colors.reshape(-1, 1, 3).astype(np.uint8),
42
+ cv2.COLOR_RGB2HSV).reshape(-1, 3).astype(float)
43
+
44
+ # Normalize HSV values
45
+ for colors_hsv in [self.shadow_colors_hsv, self.road_colors_hsv, self.water_colors_hsv]:
46
+ colors_hsv[:, 0] = colors_hsv[:, 0] * 2
47
+ colors_hsv[:, 1:] = colors_hsv[:, 1:] / 255
48
+
49
+ # Color tolerances from original segmenter
50
+ self.shadow_tolerance = {'hue': 15, 'sat': 0.15, 'val': 0.12}
51
+ self.road_tolerance = {'hue': 10, 'sat': 0.12, 'val': 0.15}
52
+ self.water_tolerance = {'hue': 20, 'sat': 0.15, 'val': 0.20}
53
+
54
+ # Output colors (BGR for OpenCV)
55
+ self.colors = {
56
+ 'black': np.array([0, 0, 0]), # Shadows
57
+ 'blue': np.array([255, 0, 0]), # Water
58
+ 'green': np.array([0, 255, 0]), # Vegetation
59
+ 'gray': np.array([128, 128, 128]), # Roads
60
+ 'brown': np.array([0, 140, 255]), # Terrain
61
+ 'white': np.array([255, 255, 255]) # Buildings
62
+ }
63
+
64
+ # Constants for height estimation
65
+ self.shadow_search_distance = 5
66
+ self.min_area_for_clustering = 1000
67
+ self.residential_height_factor = 0.6
68
+ self.isolation_threshold = 0.6
69
+
70
+ def color_distance_hsv(self, pixel_hsv, reference_hsv, tolerance):
71
+ """Calculate if a pixel is within tolerance of reference color in HSV space"""
72
+ pixel_h = float(pixel_hsv[0]) * 2
73
+ pixel_s = float(pixel_hsv[1]) / 255
74
+ pixel_v = float(pixel_hsv[2]) / 255
75
+
76
+ hue_diff = min(abs(pixel_h - reference_hsv[0]),
77
+ 360 - abs(pixel_h - reference_hsv[0]))
78
+ sat_diff = abs(pixel_s - reference_hsv[1])
79
+ val_diff = abs(pixel_v - reference_hsv[2])
80
+
81
+ return (hue_diff <= tolerance['hue'] and
82
+ sat_diff <= tolerance['sat'] and
83
+ val_diff <= tolerance['val'])
84
+
85
+ def get_dominant_surrounding_color(self, output, y, x):
86
+ """Determine dominant non-building color in neighborhood"""
87
+ height, width = output.shape[:2]
88
+ surroundings = []
89
+
90
+ for dy in [-1, 0, 1]:
91
+ for dx in [-1, 0, 1]:
92
+ if dx == 0 and dy == 0:
93
+ continue
94
+
95
+ ny, nx = y + dy, x + dx
96
+ if 0 <= ny < height and 0 <= nx < width:
97
+ pixel_color = tuple(output[ny, nx].tolist())
98
+ if not np.array_equal(output[ny, nx], self.colors['white']):
99
+ surroundings.append(pixel_color)
100
+
101
+ if not surroundings:
102
+ return None
103
+
104
+ surrounding_ratio = len(surroundings) / 8.0
105
+
106
+ if surrounding_ratio >= self.isolation_threshold:
107
+ color_counts = {}
108
+ for color in surroundings:
109
+ color_str = str(color)
110
+ color_counts[color_str] = color_counts.get(color_str, 0) + 1
111
+
112
+ most_common = max(color_counts.items(), key=lambda x: x[1])[0]
113
+ return np.array(eval(most_common))
114
+
115
+ return None
116
+
117
+ def segment_image(self, img, window_size=5):
118
+ """Segment image using improved color detection"""
119
+ hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV)
120
+ output = np.zeros_like(img)
121
+
122
+ pad = window_size // 2
123
+ hsv_pad = np.pad(hsv, ((pad, pad), (pad, pad), (0, 0)), mode='edge')
124
+
125
+ height, width = img.shape[:2]
126
+
127
+ # First pass: initial segmentation
128
+ for y in range(height):
129
+ for x in range(width):
130
+ window = hsv_pad[y:y+window_size, x:x+window_size]
131
+ center_hsv = window[pad, pad]
132
+
133
+ is_shadow = any(self.color_distance_hsv(center_hsv, ref_hsv, self.shadow_tolerance)
134
+ for ref_hsv in self.shadow_colors_hsv)
135
+
136
+ is_road = any(self.color_distance_hsv(center_hsv, ref_hsv, self.road_tolerance)
137
+ for ref_hsv in self.road_colors_hsv)
138
+
139
+ is_water = any(self.color_distance_hsv(center_hsv, ref_hsv, self.water_tolerance)
140
+ for ref_hsv in self.water_colors_hsv)
141
+
142
+ if is_shadow:
143
+ output[y, x] = self.colors['black']
144
+ elif is_water:
145
+ output[y, x] = self.colors['blue']
146
+ elif is_road:
147
+ output[y, x] = self.colors['gray']
148
+ else:
149
+ h, s, v = center_hsv
150
+ h = float(h) * 2 # Convert to 0-360 range
151
+ s = float(s) / 255
152
+ v = float(v) / 255
153
+
154
+ # Check for pinkish building tones (around red hue with specific saturation)
155
+ is_pinkish = (
156
+ ((h >= 340 or h <= 15) and # Red-pink hue range
157
+ 0.2 <= s <= 0.6 and # Moderate saturation
158
+ 0.3 <= v <= 0.7) # Moderate brightness
159
+ )
160
+
161
+ # Vegetation detection (green)
162
+ is_vegetation = (
163
+ 40 <= h <= 150 and
164
+ s >= 0.15
165
+ )
166
+
167
+ # Soil/dirt detection (yellow-brown, avoiding pinkish tones)
168
+ is_soil = (
169
+ 15 <= h <= 45 and # Yellow-brown hue range
170
+ 0.15 <= s <= 0.45 and # Lower saturation for dirt
171
+ not is_pinkish # Exclude pinkish tones
172
+ )
173
+
174
+ if is_pinkish:
175
+ output[y, x] = self.colors['white'] # Buildings
176
+ elif is_vegetation:
177
+ output[y, x] = self.colors['green'] # Vegetation
178
+ elif is_soil:
179
+ output[y, x] = self.colors['brown'] # Soil/dirt
180
+ else:
181
+ # Default to building for light-colored surfaces
182
+ output[y, x] = self.colors['white']
183
+
184
+ # Second pass: handle isolated building pixels
185
+ final_output = output.copy()
186
+ for y in range(height):
187
+ for x in range(width):
188
+ if np.array_equal(output[y, x], self.colors['white']):
189
+ dominant_color = self.get_dominant_surrounding_color(output, y, x)
190
+ if dominant_color is not None:
191
+ final_output[y, x] = dominant_color
192
+
193
+ return final_output
194
+
195
+ def estimate_heights(self, img, segmented):
196
+ """Estimate building heights"""
197
+ buildings_mask = np.all(segmented == self.colors['white'], axis=2)
198
+ shadows_mask = np.all(segmented == self.colors['black'], axis=2)
199
+
200
+ num_buildings, labels = cv2.connectedComponents(buildings_mask.astype(np.uint8))
201
+
202
+ areas = np.bincount(labels.flatten())[1:] # Skip background
203
+ max_area = np.max(areas) if len(areas) > 0 else 1
204
+
205
+ height_map = np.zeros_like(labels, dtype=np.float32)
206
+
207
+ for label in range(1, num_buildings):
208
+ building_mask = (labels == label)
209
+ if not np.any(building_mask):
210
+ continue
211
+
212
+ area = areas[label-1]
213
+ size_factor = 0.3 + 0.7 * (area / max_area)
214
+
215
+ dilated = cv2.dilate(building_mask.astype(np.uint8), np.ones((5,5), np.uint8))
216
+ shadow_ratio = np.sum(dilated & shadows_mask) / np.sum(dilated)
217
+ shadow_factor = 0.2 + 0.8 * shadow_ratio
218
+
219
+ if area >= self.min_area_for_clustering:
220
+ building_intensities = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)[building_mask]
221
+ kmeans = KMeans(n_clusters=2, random_state=42)
222
+ clusters = kmeans.fit_predict(building_intensities.reshape(-1, 1))
223
+ cluster_means = [building_intensities[clusters == i].mean() for i in range(2)]
224
+ height_factor = self.residential_height_factor if cluster_means[0] > cluster_means[1] else 1.0
225
+ else:
226
+ height_factor = 1.0
227
+
228
+ final_height = size_factor * shadow_factor * height_factor
229
+ height_map[building_mask] = final_height
230
+
231
+ return height_map * 0.15
232
+
233
+ def generate_mesh(self, height_map, texture_img, add_walls=True):
234
+ """Generate 3D mesh"""
235
+ height, width = height_map.shape
236
+
237
+ x, z = np.meshgrid(np.arange(width), np.arange(height))
238
+ vertices = np.stack([x, height_map * self.building_height, z], axis=-1)
239
+ vertices = vertices.reshape(-1, 3)
240
+
241
+ scale = max(width, height)
242
+ vertices[:, 0] = vertices[:, 0] / scale * 2 - (width / scale)
243
+ vertices[:, 2] = vertices[:, 2] / scale * 2 - (height / scale)
244
+ vertices[:, 1] = vertices[:, 1] * 2 - 1
245
+
246
+ i, j = np.meshgrid(np.arange(height-1), np.arange(width-1), indexing='ij')
247
+ v0 = (i * width + j).flatten()
248
+ v1 = v0 + 1
249
+ v2 = ((i + 1) * width + j).flatten()
250
+ v3 = v2 + 1
251
+
252
+ faces = np.vstack((
253
+ np.column_stack((v0, v2, v1)),
254
+ np.column_stack((v1, v2, v3))
255
+ ))
256
+
257
+ uvs = np.zeros((vertices.shape[0], 2))
258
+ uvs[:, 0] = x.flatten() / (width - 1)
259
+ uvs[:, 1] = 1 - (z.flatten() / (height - 1))
260
+
261
+ if len(texture_img.shape) == 3:
262
+ if texture_img.shape[2] == 4:
263
+ texture_img = cv2.cvtColor(texture_img, cv2.COLOR_BGRA2RGB)
264
+ else:
265
+ texture_img = cv2.cvtColor(texture_img, cv2.COLOR_BGR2RGB)
266
+
267
+ mesh = trimesh.Trimesh(
268
+ vertices=vertices,
269
+ faces=faces,
270
+ visual=trimesh.visual.TextureVisuals(
271
+ uv=uvs,
272
+ image=Image.fromarray(texture_img)
273
+ )
274
+ )
275
+
276
+ if add_walls:
277
+ mesh = self._add_walls(mesh, height_map)
278
+
279
+ return mesh
280
+
281
+ def _add_walls(self, mesh, height_map):
282
+ """Add vertical walls at building edges"""
283
+ edges = cv2.Canny(height_map.astype(np.uint8) * 255, 100, 200)
284
+ height, width = height_map.shape
285
+ scale = max(width, height)
286
+
287
+ edge_coords = np.column_stack(np.where(edges > 0))
288
+ if len(edge_coords) == 0:
289
+ return mesh
290
+
291
+ valid_mask = (edge_coords[:, 0] < height - 1) & (edge_coords[:, 1] < width - 1)
292
+ edge_coords = edge_coords[valid_mask]
293
+
294
+ if len(edge_coords) == 0:
295
+ return mesh
296
+
297
+ y, x = edge_coords.T
298
+ heights = height_map[y, x]
299
+
300
+ top_front = np.column_stack([x, heights * self.building_height, y])
301
+ top_back = np.column_stack([x + 1, heights * self.building_height, y])
302
+ bottom_front = np.column_stack([x, np.zeros_like(heights), y])
303
+ bottom_back = np.column_stack([x + 1, np.zeros_like(heights), y])
304
+
305
+ for vertices in [top_front, top_back, bottom_front, bottom_back]:
306
+ vertices[:, 0] = vertices[:, 0] / scale * 2 - (width / scale)
307
+ vertices[:, 2] = vertices[:, 2] / scale * 2 - (height / scale)
308
+ vertices[:, 1] = vertices[:, 1] * 2 - 1
309
+
310
+ new_vertices = np.vstack([top_front, top_back, bottom_front, bottom_back])
311
+ vertex_count = len(edge_coords)
312
+
313
+ indices = np.arange(4 * vertex_count).reshape(-1, 4)
314
+ new_faces = np.vstack([
315
+ np.column_stack([indices[:, 0], indices[:, 2], indices[:, 1]]),
316
+ np.column_stack([indices[:, 1], indices[:, 2], indices[:, 3]])
317
+ ])
318
+
319
+ base_vertex_count = len(mesh.vertices)
320
+ mesh.vertices = np.vstack((mesh.vertices, new_vertices))
321
+ mesh.faces = np.vstack((mesh.faces, new_faces + base_vertex_count))
322
+
323
+ return mesh
324
+
325
+ def main():
326
+ parser = argparse.ArgumentParser(description='Generate 3D mesh from satellite image')
327
+ parser.add_argument('input_image', help='Path to satellite image')
328
+ parser.add_argument('output_mesh', help='Path for output GLB file')
329
+ parser.add_argument('--segmented_output', help='Optional path to save segmented image')
330
+ parser.add_argument('--height', type=float, default=0.09, help='Height of buildings (default: 0.09)')
331
+ parser.add_argument('--no_walls', action='store_true', help='Skip generating vertical walls')
332
+ parser.add_argument('--window_size', type=int, default=5, help='Window size for segmentation analysis')
333
+
334
+ args = parser.parse_args()
335
+
336
+ # Load image
337
+ img = cv2.imread(args.input_image)
338
+ if img is None:
339
+ raise ValueError(f"Could not read image at {args.input_image}")
340
+
341
+ generator = SatelliteModelGenerator(building_height=args.height)
342
+
343
+ # Process image
344
+ print("Segmenting image...")
345
+ segmented_img = generator.segment_image(img, args.window_size)
346
+
347
+ print("Estimating heights...")
348
+ height_map = generator.estimate_heights(img, segmented_img)
349
+
350
+ # Save segmented image if requested
351
+ if args.segmented_output:
352
+ cv2.imwrite(args.segmented_output, segmented_img)
353
+ print(f"Segmented image saved to {args.segmented_output}")
354
+
355
+ # Generate and save mesh
356
+ print("Generating mesh...")
357
+ mesh = generator.generate_mesh(height_map, img, add_walls=not args.no_walls)
358
+ mesh.export(args.output_mesh)
359
+ print(f"Mesh exported to {args.output_mesh}")
360
+
361
+ if __name__ == "__main__":
362
+ main()