voxcity 0.4.7__py3-none-any.whl → 0.5.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of voxcity might be problematic. Click here for more details.
- voxcity/downloader/citygml.py +556 -0
- voxcity/generator.py +121 -1
- voxcity/geoprocessor/grid.py +251 -2
- {voxcity-0.4.7.dist-info → voxcity-0.5.1.dist-info}/METADATA +2 -1
- {voxcity-0.4.7.dist-info → voxcity-0.5.1.dist-info}/RECORD +9 -8
- {voxcity-0.4.7.dist-info → voxcity-0.5.1.dist-info}/WHEEL +1 -1
- {voxcity-0.4.7.dist-info → voxcity-0.5.1.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-0.4.7.dist-info → voxcity-0.5.1.dist-info}/licenses/LICENSE +0 -0
- {voxcity-0.4.7.dist-info → voxcity-0.5.1.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import zipfile
|
|
3
|
+
import io
|
|
4
|
+
import os
|
|
5
|
+
import numpy as np
|
|
6
|
+
from urllib.parse import urlparse
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
import lxml.etree as ET
|
|
9
|
+
import geopandas as gpd
|
|
10
|
+
from shapely.geometry import Polygon, Point, MultiPolygon
|
|
11
|
+
import pandas as pd
|
|
12
|
+
from tqdm import tqdm
|
|
13
|
+
|
|
14
|
+
def download_and_extract_zip(url, extract_to='.'):
|
|
15
|
+
"""
|
|
16
|
+
Download and extract a zip file from a URL
|
|
17
|
+
"""
|
|
18
|
+
# Send a GET request to the URL
|
|
19
|
+
response = requests.get(url)
|
|
20
|
+
|
|
21
|
+
# Check if the request was successful
|
|
22
|
+
if response.status_code == 200:
|
|
23
|
+
# Extract the base name of the zip file from the URL
|
|
24
|
+
parsed_url = urlparse(url)
|
|
25
|
+
zip_filename = os.path.basename(parsed_url.path)
|
|
26
|
+
folder_name = os.path.splitext(zip_filename)[0] # Remove the .zip extension
|
|
27
|
+
|
|
28
|
+
# Create the extraction directory
|
|
29
|
+
extraction_path = os.path.join(extract_to, folder_name)
|
|
30
|
+
os.makedirs(extraction_path, exist_ok=True)
|
|
31
|
+
|
|
32
|
+
# Create a BytesIO object from the response content
|
|
33
|
+
zip_file = io.BytesIO(response.content)
|
|
34
|
+
|
|
35
|
+
# Open the zip file
|
|
36
|
+
with zipfile.ZipFile(zip_file) as z:
|
|
37
|
+
# Extract all the contents of the zip file to the specified directory
|
|
38
|
+
z.extractall(extraction_path)
|
|
39
|
+
print(f"Extracted to {extraction_path}")
|
|
40
|
+
else:
|
|
41
|
+
print(f"Failed to download the file. Status code: {response.status_code}")
|
|
42
|
+
|
|
43
|
+
return extraction_path, folder_name
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def validate_coords(coords):
|
|
47
|
+
"""
|
|
48
|
+
Validate that coordinates are not infinite or NaN
|
|
49
|
+
"""
|
|
50
|
+
return all(not np.isinf(x) and not np.isnan(x) for coord in coords for x in coord)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def swap_coordinates(polygon):
|
|
54
|
+
"""
|
|
55
|
+
Swap coordinates in a polygon (lat/lon to lon/lat or vice versa)
|
|
56
|
+
"""
|
|
57
|
+
if isinstance(polygon, MultiPolygon):
|
|
58
|
+
# Handle MultiPolygon objects
|
|
59
|
+
new_polygons = []
|
|
60
|
+
for geom in polygon.geoms:
|
|
61
|
+
coords = list(geom.exterior.coords)
|
|
62
|
+
swapped_coords = [(y, x) for x, y in coords]
|
|
63
|
+
new_polygons.append(Polygon(swapped_coords))
|
|
64
|
+
return MultiPolygon(new_polygons)
|
|
65
|
+
else:
|
|
66
|
+
# Handle regular Polygon objects
|
|
67
|
+
coords = list(polygon.exterior.coords)
|
|
68
|
+
swapped_coords = [(y, x) for x, y in coords]
|
|
69
|
+
return Polygon(swapped_coords)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def extract_terrain_info(file_path, namespaces):
|
|
73
|
+
"""
|
|
74
|
+
Extract terrain elevation information from a CityGML file
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
tree = ET.parse(file_path)
|
|
78
|
+
root = tree.getroot()
|
|
79
|
+
|
|
80
|
+
terrain_elements = []
|
|
81
|
+
|
|
82
|
+
# Look for Relief features in the CityGML file
|
|
83
|
+
for relief in root.findall('.//dem:ReliefFeature', namespaces):
|
|
84
|
+
relief_id = relief.get('{http://www.opengis.net/gml}id')
|
|
85
|
+
|
|
86
|
+
# Extract TIN Relief components
|
|
87
|
+
for tin in relief.findall('.//dem:TINRelief', namespaces):
|
|
88
|
+
tin_id = tin.get('{http://www.opengis.net/gml}id')
|
|
89
|
+
|
|
90
|
+
# Extract triangulated surface
|
|
91
|
+
triangles = tin.findall('.//gml:Triangle', namespaces)
|
|
92
|
+
for i, triangle in enumerate(triangles):
|
|
93
|
+
# Extract the coordinates of each triangle
|
|
94
|
+
pos_lists = triangle.findall('.//gml:posList', namespaces)
|
|
95
|
+
|
|
96
|
+
for pos_list in pos_lists:
|
|
97
|
+
try:
|
|
98
|
+
# Process the coordinates
|
|
99
|
+
coords_text = pos_list.text.strip().split()
|
|
100
|
+
coords = []
|
|
101
|
+
elevations = []
|
|
102
|
+
|
|
103
|
+
# Process coordinates in triplets (x, y, z)
|
|
104
|
+
for j in range(0, len(coords_text), 3):
|
|
105
|
+
if j + 2 < len(coords_text):
|
|
106
|
+
x = float(coords_text[j])
|
|
107
|
+
y = float(coords_text[j + 1])
|
|
108
|
+
z = float(coords_text[j + 2]) # Elevation
|
|
109
|
+
|
|
110
|
+
if not np.isinf(x) and not np.isinf(y) and not np.isinf(z):
|
|
111
|
+
coords.append((x, y))
|
|
112
|
+
elevations.append(z)
|
|
113
|
+
|
|
114
|
+
if len(coords) >= 3 and validate_coords(coords):
|
|
115
|
+
polygon = Polygon(coords)
|
|
116
|
+
if polygon.is_valid:
|
|
117
|
+
# Calculate centroid for point representation
|
|
118
|
+
centroid = polygon.centroid
|
|
119
|
+
avg_elevation = np.mean(elevations)
|
|
120
|
+
|
|
121
|
+
terrain_elements.append({
|
|
122
|
+
'relief_id': relief_id,
|
|
123
|
+
'tin_id': tin_id,
|
|
124
|
+
'triangle_id': f"{tin_id}_tri_{i}",
|
|
125
|
+
'elevation': avg_elevation,
|
|
126
|
+
'geometry': centroid,
|
|
127
|
+
'polygon': polygon,
|
|
128
|
+
'source_file': Path(file_path).name
|
|
129
|
+
})
|
|
130
|
+
except (ValueError, IndexError) as e:
|
|
131
|
+
print(f"Error processing triangle in relief {relief_id}: {e}")
|
|
132
|
+
continue
|
|
133
|
+
|
|
134
|
+
# Extract breaklines
|
|
135
|
+
for breakline in relief.findall('.//dem:breaklines', namespaces):
|
|
136
|
+
for line in breakline.findall('.//gml:LineString', namespaces):
|
|
137
|
+
line_id = line.get('{http://www.opengis.net/gml}id')
|
|
138
|
+
pos_list = line.find('.//gml:posList', namespaces)
|
|
139
|
+
|
|
140
|
+
if pos_list is not None:
|
|
141
|
+
try:
|
|
142
|
+
coords_text = pos_list.text.strip().split()
|
|
143
|
+
points = []
|
|
144
|
+
elevations = []
|
|
145
|
+
|
|
146
|
+
for j in range(0, len(coords_text), 3):
|
|
147
|
+
if j + 2 < len(coords_text):
|
|
148
|
+
x = float(coords_text[j])
|
|
149
|
+
y = float(coords_text[j + 1])
|
|
150
|
+
z = float(coords_text[j + 2])
|
|
151
|
+
|
|
152
|
+
if not np.isinf(x) and not np.isinf(y) and not np.isinf(z):
|
|
153
|
+
points.append(Point(x, y))
|
|
154
|
+
elevations.append(z)
|
|
155
|
+
|
|
156
|
+
for k, point in enumerate(points):
|
|
157
|
+
if point.is_valid:
|
|
158
|
+
terrain_elements.append({
|
|
159
|
+
'relief_id': relief_id,
|
|
160
|
+
'breakline_id': line_id,
|
|
161
|
+
'point_id': f"{line_id}_pt_{k}",
|
|
162
|
+
'elevation': elevations[k],
|
|
163
|
+
'geometry': point,
|
|
164
|
+
'polygon': None,
|
|
165
|
+
'source_file': Path(file_path).name
|
|
166
|
+
})
|
|
167
|
+
except (ValueError, IndexError) as e:
|
|
168
|
+
print(f"Error processing breakline {line_id}: {e}")
|
|
169
|
+
continue
|
|
170
|
+
|
|
171
|
+
# Extract mass points
|
|
172
|
+
for mass_point in relief.findall('.//dem:massPoint', namespaces):
|
|
173
|
+
for point in mass_point.findall('.//gml:Point', namespaces):
|
|
174
|
+
point_id = point.get('{http://www.opengis.net/gml}id')
|
|
175
|
+
pos = point.find('.//gml:pos', namespaces)
|
|
176
|
+
|
|
177
|
+
if pos is not None:
|
|
178
|
+
try:
|
|
179
|
+
coords = pos.text.strip().split()
|
|
180
|
+
if len(coords) >= 3:
|
|
181
|
+
x = float(coords[0])
|
|
182
|
+
y = float(coords[1])
|
|
183
|
+
z = float(coords[2])
|
|
184
|
+
|
|
185
|
+
if not np.isinf(x) and not np.isinf(y) and not np.isinf(z):
|
|
186
|
+
point_geom = Point(x, y)
|
|
187
|
+
if point_geom.is_valid:
|
|
188
|
+
terrain_elements.append({
|
|
189
|
+
'relief_id': relief_id,
|
|
190
|
+
'mass_point_id': point_id,
|
|
191
|
+
'elevation': z,
|
|
192
|
+
'geometry': point_geom,
|
|
193
|
+
'polygon': None,
|
|
194
|
+
'source_file': Path(file_path).name
|
|
195
|
+
})
|
|
196
|
+
except (ValueError, IndexError) as e:
|
|
197
|
+
print(f"Error processing mass point {point_id}: {e}")
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
print(f"Extracted {len(terrain_elements)} terrain elements from {Path(file_path).name}")
|
|
201
|
+
return terrain_elements
|
|
202
|
+
|
|
203
|
+
except Exception as e:
|
|
204
|
+
print(f"Error processing terrain in file {Path(file_path).name}: {e}")
|
|
205
|
+
return []
|
|
206
|
+
|
|
207
|
+
def extract_vegetation_info(file_path, namespaces):
|
|
208
|
+
"""
|
|
209
|
+
Extract vegetation features (PlantCover, SolitaryVegetationObject)
|
|
210
|
+
from a CityGML file, handling LOD0..LOD3 geometry and MultiSurface/CompositeSurface.
|
|
211
|
+
"""
|
|
212
|
+
vegetation_elements = []
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
tree = ET.parse(file_path)
|
|
216
|
+
root = tree.getroot()
|
|
217
|
+
except Exception as e:
|
|
218
|
+
print(f"Error parsing CityGML file {Path(file_path).name}: {e}")
|
|
219
|
+
return vegetation_elements
|
|
220
|
+
|
|
221
|
+
# ----------------------------------------------------------------------------
|
|
222
|
+
# Helper: parse all polygons from a <gml:MultiSurface> or <veg:lodXMultiSurface>
|
|
223
|
+
# ----------------------------------------------------------------------------
|
|
224
|
+
def parse_lod_multisurface(lod_elem):
|
|
225
|
+
"""Return a Shapely (Multi)Polygon from gml:Polygon elements under lod_elem."""
|
|
226
|
+
polygons = []
|
|
227
|
+
# Find all Polygons (including nested in CompositeSurface)
|
|
228
|
+
for poly_node in lod_elem.findall('.//gml:Polygon', namespaces):
|
|
229
|
+
ring_node = poly_node.find('.//gml:exterior//gml:LinearRing//gml:posList', namespaces)
|
|
230
|
+
if ring_node is None or ring_node.text is None:
|
|
231
|
+
continue
|
|
232
|
+
|
|
233
|
+
# Parse coordinate text
|
|
234
|
+
coords_text = ring_node.text.strip().split()
|
|
235
|
+
coords = []
|
|
236
|
+
# Typically posList is in triplets: (x, y, z)
|
|
237
|
+
for i in range(0, len(coords_text), 3):
|
|
238
|
+
try:
|
|
239
|
+
x = float(coords_text[i])
|
|
240
|
+
y = float(coords_text[i+1])
|
|
241
|
+
# z = float(coords_text[i+2]) # if you want z
|
|
242
|
+
coords.append((x, y))
|
|
243
|
+
except:
|
|
244
|
+
# Skip any parse error or incomplete coordinate
|
|
245
|
+
pass
|
|
246
|
+
|
|
247
|
+
if len(coords) >= 3:
|
|
248
|
+
polygon = Polygon(coords)
|
|
249
|
+
if polygon.is_valid:
|
|
250
|
+
polygons.append(polygon)
|
|
251
|
+
|
|
252
|
+
if not polygons:
|
|
253
|
+
return None
|
|
254
|
+
elif len(polygons) == 1:
|
|
255
|
+
return polygons[0]
|
|
256
|
+
else:
|
|
257
|
+
return MultiPolygon(polygons)
|
|
258
|
+
|
|
259
|
+
# ----------------------------------------------------------------------------
|
|
260
|
+
# Helper: retrieve geometry from all LOD tags
|
|
261
|
+
# ----------------------------------------------------------------------------
|
|
262
|
+
def get_veg_geometry(veg_elem):
|
|
263
|
+
"""
|
|
264
|
+
Search for geometry under lod0Geometry, lod1Geometry, lod2Geometry,
|
|
265
|
+
lod3Geometry, lod4Geometry, as well as lod0MultiSurface ... lod3MultiSurface, etc.
|
|
266
|
+
Return a Shapely geometry (Polygon or MultiPolygon) if found.
|
|
267
|
+
"""
|
|
268
|
+
geometry_lods = [
|
|
269
|
+
"lod0Geometry", "lod1Geometry", "lod2Geometry", "lod3Geometry", "lod4Geometry",
|
|
270
|
+
"lod0MultiSurface", "lod1MultiSurface", "lod2MultiSurface", "lod3MultiSurface", "lod4MultiSurface"
|
|
271
|
+
]
|
|
272
|
+
|
|
273
|
+
for lod_tag in geometry_lods:
|
|
274
|
+
# e.g. .//veg:lod3Geometry
|
|
275
|
+
lod_elem = veg_elem.find(f'.//veg:{lod_tag}', namespaces)
|
|
276
|
+
if lod_elem is not None:
|
|
277
|
+
geom = parse_lod_multisurface(lod_elem)
|
|
278
|
+
if geom is not None:
|
|
279
|
+
return geom
|
|
280
|
+
|
|
281
|
+
return None
|
|
282
|
+
|
|
283
|
+
# ----------------------------------------------------------------------------
|
|
284
|
+
# 1) PlantCover
|
|
285
|
+
# ----------------------------------------------------------------------------
|
|
286
|
+
for plant_cover in root.findall('.//veg:PlantCover', namespaces):
|
|
287
|
+
cover_id = plant_cover.get('{http://www.opengis.net/gml}id')
|
|
288
|
+
# averageHeight (if present)
|
|
289
|
+
avg_height_elem = plant_cover.find('.//veg:averageHeight', namespaces)
|
|
290
|
+
if avg_height_elem is not None and avg_height_elem.text:
|
|
291
|
+
try:
|
|
292
|
+
vegetation_height = float(avg_height_elem.text)
|
|
293
|
+
except:
|
|
294
|
+
vegetation_height = None
|
|
295
|
+
else:
|
|
296
|
+
vegetation_height = None
|
|
297
|
+
|
|
298
|
+
# parse geometry from LOD0..LOD3
|
|
299
|
+
geometry = get_veg_geometry(plant_cover)
|
|
300
|
+
|
|
301
|
+
if geometry is not None and not geometry.is_empty:
|
|
302
|
+
vegetation_elements.append({
|
|
303
|
+
'object_type': 'PlantCover',
|
|
304
|
+
'vegetation_id': cover_id,
|
|
305
|
+
'height': vegetation_height,
|
|
306
|
+
'geometry': geometry,
|
|
307
|
+
'source_file': Path(file_path).name
|
|
308
|
+
})
|
|
309
|
+
|
|
310
|
+
# ----------------------------------------------------------------------------
|
|
311
|
+
# 2) SolitaryVegetationObject
|
|
312
|
+
# ----------------------------------------------------------------------------
|
|
313
|
+
for solitary in root.findall('.//veg:SolitaryVegetationObject', namespaces):
|
|
314
|
+
veg_id = solitary.get('{http://www.opengis.net/gml}id')
|
|
315
|
+
height_elem = solitary.find('.//veg:height', namespaces)
|
|
316
|
+
if height_elem is not None and height_elem.text:
|
|
317
|
+
try:
|
|
318
|
+
veg_height = float(height_elem.text)
|
|
319
|
+
except:
|
|
320
|
+
veg_height = None
|
|
321
|
+
else:
|
|
322
|
+
veg_height = None
|
|
323
|
+
|
|
324
|
+
geometry = get_veg_geometry(solitary)
|
|
325
|
+
if geometry is not None and not geometry.is_empty:
|
|
326
|
+
vegetation_elements.append({
|
|
327
|
+
'object_type': 'SolitaryVegetationObject',
|
|
328
|
+
'vegetation_id': veg_id,
|
|
329
|
+
'height': veg_height,
|
|
330
|
+
'geometry': geometry,
|
|
331
|
+
'source_file': Path(file_path).name
|
|
332
|
+
})
|
|
333
|
+
|
|
334
|
+
if vegetation_elements:
|
|
335
|
+
print(f"Extracted {len(vegetation_elements)} vegetation objects from {Path(file_path).name}")
|
|
336
|
+
return vegetation_elements
|
|
337
|
+
|
|
338
|
+
def process_citygml_file(file_path):
|
|
339
|
+
"""
|
|
340
|
+
Process a CityGML file to extract building, terrain, and vegetation information
|
|
341
|
+
"""
|
|
342
|
+
buildings = []
|
|
343
|
+
terrain_elements = []
|
|
344
|
+
vegetation_elements = []
|
|
345
|
+
|
|
346
|
+
# Namespaces (now includes 'veg')
|
|
347
|
+
namespaces = {
|
|
348
|
+
'core': 'http://www.opengis.net/citygml/2.0',
|
|
349
|
+
'bldg': 'http://www.opengis.net/citygml/building/2.0',
|
|
350
|
+
'gml': 'http://www.opengis.net/gml',
|
|
351
|
+
'uro': 'https://www.geospatial.jp/iur/uro/3.0',
|
|
352
|
+
'dem': 'http://www.opengis.net/citygml/relief/2.0',
|
|
353
|
+
'veg': 'http://www.opengis.net/citygml/vegetation/2.0'
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
try:
|
|
357
|
+
# Parse the file once at the start (optional; if you want to share 'root' among sub-extractors)
|
|
358
|
+
tree = ET.parse(file_path)
|
|
359
|
+
root = tree.getroot()
|
|
360
|
+
|
|
361
|
+
# --- Extract Building Info (existing approach) ---
|
|
362
|
+
for building in root.findall('.//bldg:Building', namespaces):
|
|
363
|
+
building_id = building.get('{http://www.opengis.net/gml}id')
|
|
364
|
+
measured_height = building.find('.//bldg:measuredHeight', namespaces)
|
|
365
|
+
height = float(measured_height.text) if measured_height is not None else None
|
|
366
|
+
|
|
367
|
+
# Extract the footprint (LOD0)
|
|
368
|
+
lod0_roof_edge = building.find('.//bldg:lod0RoofEdge//gml:posList', namespaces)
|
|
369
|
+
if lod0_roof_edge is not None:
|
|
370
|
+
try:
|
|
371
|
+
pos_list = lod0_roof_edge.text.strip().split()
|
|
372
|
+
coords = []
|
|
373
|
+
for i in range(0, len(pos_list), 3):
|
|
374
|
+
if i + 2 < len(pos_list):
|
|
375
|
+
lon = float(pos_list[i])
|
|
376
|
+
lat = float(pos_list[i + 1])
|
|
377
|
+
elevation = float(pos_list[i + 2]) # z value
|
|
378
|
+
if not np.isinf(lon) and not np.isinf(lat):
|
|
379
|
+
coords.append((lon, lat))
|
|
380
|
+
|
|
381
|
+
if len(coords) >= 3 and validate_coords(coords):
|
|
382
|
+
polygon = Polygon(coords)
|
|
383
|
+
if polygon.is_valid:
|
|
384
|
+
buildings.append({
|
|
385
|
+
'building_id': building_id,
|
|
386
|
+
'height': height,
|
|
387
|
+
'ground_elevation': elevation, # Add ground elevation if relevant
|
|
388
|
+
'geometry': polygon,
|
|
389
|
+
'source_file': Path(file_path).name
|
|
390
|
+
})
|
|
391
|
+
except (ValueError, IndexError) as e:
|
|
392
|
+
print(f"Error processing building {building_id} in file {Path(file_path).name}: {e}")
|
|
393
|
+
|
|
394
|
+
# --- Extract Terrain Info (existing function) ---
|
|
395
|
+
terrain_elements = extract_terrain_info(file_path, namespaces)
|
|
396
|
+
|
|
397
|
+
# --- Extract Vegetation Info (new function) ---
|
|
398
|
+
vegetation_elements = extract_vegetation_info(file_path, namespaces)
|
|
399
|
+
|
|
400
|
+
print(f"Processed {Path(file_path).name}: "
|
|
401
|
+
f"{len(buildings)} buildings, {len(terrain_elements)} terrain elements, "
|
|
402
|
+
f"{len(vegetation_elements)} vegetation objects")
|
|
403
|
+
|
|
404
|
+
except Exception as e:
|
|
405
|
+
print(f"Error processing file {Path(file_path).name}: {e}")
|
|
406
|
+
|
|
407
|
+
return buildings, terrain_elements, vegetation_elements
|
|
408
|
+
|
|
409
|
+
def load_plateau_with_terrain(url, base_dir):
|
|
410
|
+
"""
|
|
411
|
+
Load PLATEAU data, extracting Buildings, Terrain, and Vegetation data from CityGML.
|
|
412
|
+
"""
|
|
413
|
+
# 1) Download & unzip
|
|
414
|
+
citygml_path, foldername = download_and_extract_zip(url, extract_to=base_dir)
|
|
415
|
+
|
|
416
|
+
# 2) Identify CityGML files in typical folder structure
|
|
417
|
+
try:
|
|
418
|
+
citygml_dir = os.path.join(citygml_path, 'udx')
|
|
419
|
+
if not os.path.exists(citygml_dir):
|
|
420
|
+
citygml_dir = os.path.join(citygml_path, foldername, 'udx')
|
|
421
|
+
|
|
422
|
+
bldg_dir = os.path.join(citygml_dir, 'bldg')
|
|
423
|
+
dem_dir = os.path.join(citygml_dir, 'dem')
|
|
424
|
+
|
|
425
|
+
# NEW: check for vegetation folder
|
|
426
|
+
veg_dir = os.path.join(citygml_dir, 'veg')
|
|
427
|
+
|
|
428
|
+
citygml_files = []
|
|
429
|
+
|
|
430
|
+
# If there's a building folder, gather .gml from there
|
|
431
|
+
if os.path.exists(bldg_dir):
|
|
432
|
+
citygml_files += [
|
|
433
|
+
os.path.join(bldg_dir, f) for f in os.listdir(bldg_dir) if f.endswith('.gml')
|
|
434
|
+
]
|
|
435
|
+
else:
|
|
436
|
+
# If no 'bldg' folder, look directly in 'udx'
|
|
437
|
+
citygml_files += [
|
|
438
|
+
os.path.join(citygml_dir, f) for f in os.listdir(citygml_dir) if f.endswith('.gml')
|
|
439
|
+
]
|
|
440
|
+
|
|
441
|
+
# Also gather DEM .gml (terrain)
|
|
442
|
+
if os.path.exists(dem_dir):
|
|
443
|
+
citygml_files += [
|
|
444
|
+
os.path.join(dem_dir, f) for f in os.listdir(dem_dir) if f.endswith('.gml')
|
|
445
|
+
]
|
|
446
|
+
|
|
447
|
+
# ADD THIS: gather VEG .gml (vegetation)
|
|
448
|
+
if os.path.exists(veg_dir):
|
|
449
|
+
citygml_files += [
|
|
450
|
+
os.path.join(veg_dir, f) for f in os.listdir(veg_dir) if f.endswith('.gml')
|
|
451
|
+
]
|
|
452
|
+
|
|
453
|
+
total_files = len(citygml_files)
|
|
454
|
+
print(f"Found {total_files} CityGML files to process")
|
|
455
|
+
|
|
456
|
+
except Exception as e:
|
|
457
|
+
print(f"Error finding CityGML files: {e}")
|
|
458
|
+
return None, None, None
|
|
459
|
+
|
|
460
|
+
all_buildings = []
|
|
461
|
+
all_terrain = []
|
|
462
|
+
all_vegetation = []
|
|
463
|
+
|
|
464
|
+
# 3) Process each CityGML
|
|
465
|
+
for file_path in tqdm(citygml_files, desc="Processing CityGML files"):
|
|
466
|
+
buildings, terrain_elements, vegetation_elements = process_citygml_file(file_path)
|
|
467
|
+
all_buildings.extend(buildings)
|
|
468
|
+
all_terrain.extend(terrain_elements)
|
|
469
|
+
all_vegetation.extend(vegetation_elements)
|
|
470
|
+
|
|
471
|
+
# 4) Create GeoDataFrame for Buildings
|
|
472
|
+
if all_buildings:
|
|
473
|
+
gdf_buildings = gpd.GeoDataFrame(all_buildings, geometry='geometry')
|
|
474
|
+
gdf_buildings.set_crs(epsg=6697, inplace=True)
|
|
475
|
+
|
|
476
|
+
# Swap coords from (lon, lat) to (lat, lon) if needed
|
|
477
|
+
swapped_geometries = [swap_coordinates(geom) for geom in gdf_buildings.geometry]
|
|
478
|
+
gdf_buildings_swapped = gpd.GeoDataFrame(
|
|
479
|
+
{
|
|
480
|
+
'building_id': gdf_buildings['building_id'],
|
|
481
|
+
'height': gdf_buildings['height'],
|
|
482
|
+
'ground_elevation': gdf_buildings['ground_elevation'],
|
|
483
|
+
'source_file': gdf_buildings['source_file'],
|
|
484
|
+
'geometry': swapped_geometries
|
|
485
|
+
},
|
|
486
|
+
crs='EPSG:6697'
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
# Save
|
|
490
|
+
gdf_buildings_swapped['id'] = gdf_buildings_swapped.index
|
|
491
|
+
gdf_buildings_swapped.to_file('all_buildings_with_elevation.geojson', driver='GeoJSON')
|
|
492
|
+
print(f"\nBuildings saved to all_buildings_with_elevation.geojson")
|
|
493
|
+
else:
|
|
494
|
+
gdf_buildings_swapped = None
|
|
495
|
+
|
|
496
|
+
# 5) Create GeoDataFrame for Terrain
|
|
497
|
+
if all_terrain:
|
|
498
|
+
gdf_terrain = gpd.GeoDataFrame(all_terrain, geometry='geometry')
|
|
499
|
+
gdf_terrain.set_crs(epsg=6697, inplace=True)
|
|
500
|
+
|
|
501
|
+
swapped_geometries = []
|
|
502
|
+
for geom in gdf_terrain.geometry:
|
|
503
|
+
if isinstance(geom, (Polygon, MultiPolygon)):
|
|
504
|
+
swapped_geometries.append(swap_coordinates(geom))
|
|
505
|
+
elif isinstance(geom, Point):
|
|
506
|
+
swapped_geometries.append(Point(geom.y, geom.x))
|
|
507
|
+
else:
|
|
508
|
+
swapped_geometries.append(geom)
|
|
509
|
+
|
|
510
|
+
terrain_data = {
|
|
511
|
+
'relief_id': gdf_terrain.get('relief_id', ''),
|
|
512
|
+
'tin_id': gdf_terrain.get('tin_id', ''),
|
|
513
|
+
'triangle_id': gdf_terrain.get('triangle_id', ''),
|
|
514
|
+
'breakline_id': gdf_terrain.get('breakline_id', ''),
|
|
515
|
+
'mass_point_id': gdf_terrain.get('mass_point_id', ''),
|
|
516
|
+
'point_id': gdf_terrain.get('point_id', ''),
|
|
517
|
+
'elevation': gdf_terrain['elevation'],
|
|
518
|
+
'source_file': gdf_terrain['source_file'],
|
|
519
|
+
'geometry': swapped_geometries
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
gdf_terrain_swapped = gpd.GeoDataFrame(terrain_data, geometry='geometry', crs='EPSG:6697')
|
|
523
|
+
gdf_terrain_swapped.to_file('terrain_elevation.geojson', driver='GeoJSON')
|
|
524
|
+
print(f"Terrain saved to terrain_elevation.geojson")
|
|
525
|
+
else:
|
|
526
|
+
gdf_terrain_swapped = None
|
|
527
|
+
|
|
528
|
+
# 6) Create GeoDataFrame for Vegetation
|
|
529
|
+
if all_vegetation:
|
|
530
|
+
gdf_veg = gpd.GeoDataFrame(all_vegetation, geometry='geometry')
|
|
531
|
+
gdf_veg.set_crs(epsg=6697, inplace=True)
|
|
532
|
+
|
|
533
|
+
swapped_geometries = []
|
|
534
|
+
for geom in gdf_veg.geometry:
|
|
535
|
+
if isinstance(geom, (Polygon, MultiPolygon)):
|
|
536
|
+
swapped_geometries.append(swap_coordinates(geom))
|
|
537
|
+
elif isinstance(geom, Point):
|
|
538
|
+
swapped_geometries.append(Point(geom.y, geom.x))
|
|
539
|
+
else:
|
|
540
|
+
swapped_geometries.append(geom)
|
|
541
|
+
|
|
542
|
+
vegetation_data = {
|
|
543
|
+
'object_type': gdf_veg.get('object_type', ''),
|
|
544
|
+
'vegetation_id': gdf_veg.get('vegetation_id', ''),
|
|
545
|
+
'height': gdf_veg.get('height', None),
|
|
546
|
+
'avg_elevation': gdf_veg.get('avg_elevation', None), # Use .get() with a default
|
|
547
|
+
'source_file': gdf_veg.get('source_file', ''),
|
|
548
|
+
'geometry': swapped_geometries
|
|
549
|
+
}
|
|
550
|
+
gdf_vegetation_swapped = gpd.GeoDataFrame(vegetation_data, geometry='geometry', crs='EPSG:6697')
|
|
551
|
+
gdf_vegetation_swapped.to_file('vegetation_elevation.geojson', driver='GeoJSON')
|
|
552
|
+
print(f"Vegetation saved to vegetation_elevation.geojson")
|
|
553
|
+
else:
|
|
554
|
+
gdf_vegetation_swapped = None
|
|
555
|
+
|
|
556
|
+
return gdf_buildings_swapped, gdf_terrain_swapped, gdf_vegetation_swapped
|
voxcity/generator.py
CHANGED
|
@@ -24,6 +24,7 @@ from .downloader.oemj import save_oemj_as_geotiff
|
|
|
24
24
|
from .downloader.omt import load_gdf_from_openmaptiles
|
|
25
25
|
from .downloader.eubucco import load_gdf_from_eubucco
|
|
26
26
|
from .downloader.overture import load_gdf_from_overture
|
|
27
|
+
from .downloader.citygml import load_plateau_with_terrain
|
|
27
28
|
from .downloader.gee import (
|
|
28
29
|
initialize_earth_engine,
|
|
29
30
|
get_roi,
|
|
@@ -82,7 +83,7 @@ def get_land_cover_grid(rectangle_vertices, meshsize, source, output_dir, **kwar
|
|
|
82
83
|
print(f"Data source: {source}")
|
|
83
84
|
|
|
84
85
|
# Initialize Earth Engine for accessing satellite data
|
|
85
|
-
if source
|
|
86
|
+
if source not in ["OpenStreetMap", "OpenEarthMapJapan"]:
|
|
86
87
|
initialize_earth_engine()
|
|
87
88
|
|
|
88
89
|
# Create output directory if it doesn't exist
|
|
@@ -691,6 +692,125 @@ def get_voxcity(rectangle_vertices, building_source, land_cover_source, canopy_h
|
|
|
691
692
|
|
|
692
693
|
return voxcity_grid, building_height_grid, building_min_height_grid, building_id_grid, canopy_height_grid, land_cover_grid, dem_grid, building_gdf
|
|
693
694
|
|
|
695
|
+
def get_voxcity_CityGML(rectangle_vertices, url_citygml, land_cover_source, canopy_height_source, meshsize, **kwargs):
|
|
696
|
+
"""Main function to generate a complete voxel city model.
|
|
697
|
+
|
|
698
|
+
Args:
|
|
699
|
+
rectangle_vertices: List of coordinates defining the area of interest
|
|
700
|
+
building_source: Source for building height data (e.g. 'OSM', 'EUBUCCO')
|
|
701
|
+
land_cover_source: Source for land cover data (e.g. 'ESA', 'ESRI')
|
|
702
|
+
canopy_height_source: Source for tree canopy height data
|
|
703
|
+
dem_source: Source for digital elevation model data ('Flat' or other source)
|
|
704
|
+
meshsize: Size of each grid cell in meters
|
|
705
|
+
**kwargs: Additional keyword arguments including:
|
|
706
|
+
- output_dir: Directory to save output files (default: 'output')
|
|
707
|
+
- min_canopy_height: Minimum height threshold for tree canopy
|
|
708
|
+
- remove_perimeter_object: Factor to remove objects near perimeter
|
|
709
|
+
- mapvis: Whether to visualize grids on map
|
|
710
|
+
- voxelvis: Whether to visualize 3D voxel model
|
|
711
|
+
- voxelvis_img_save_path: Path to save 3D visualization
|
|
712
|
+
|
|
713
|
+
Returns:
|
|
714
|
+
tuple containing:
|
|
715
|
+
- voxcity_grid: 3D voxel grid of the complete city model
|
|
716
|
+
- building_height_grid: 2D grid of building heights
|
|
717
|
+
- building_min_height_grid: 2D grid of minimum building heights
|
|
718
|
+
- building_id_grid: 2D grid of building IDs
|
|
719
|
+
- canopy_height_grid: 2D grid of tree canopy heights
|
|
720
|
+
- land_cover_grid: 2D grid of land cover classifications
|
|
721
|
+
- dem_grid: 2D grid of ground elevation
|
|
722
|
+
- building_geojson: GeoJSON of building footprints and metadata
|
|
723
|
+
"""
|
|
724
|
+
# Create output directory if it doesn't exist
|
|
725
|
+
output_dir = kwargs.get("output_dir", "output")
|
|
726
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
727
|
+
|
|
728
|
+
# Remove 'output_dir' from kwargs to prevent duplication
|
|
729
|
+
kwargs.pop('output_dir', None)
|
|
730
|
+
|
|
731
|
+
# get all required gdfs
|
|
732
|
+
building_gdf, terrain_gdf, vegetation_gdf = load_plateau_with_terrain(url_citygml, base_dir=output_dir)
|
|
733
|
+
|
|
734
|
+
land_cover_grid = get_land_cover_grid(rectangle_vertices, meshsize, land_cover_source, output_dir, **kwargs)
|
|
735
|
+
|
|
736
|
+
# building_height_grid, building_min_height_grid, building_id_grid, building_gdf = get_building_height_grid(rectangle_vertices, meshsize, building_source, output_dir, **kwargs)
|
|
737
|
+
building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(buildings_gdf, meshsize, rectangle_vertices, **kwargs)
|
|
738
|
+
|
|
739
|
+
# Visualize grid if requested
|
|
740
|
+
grid_vis = kwargs.get("gridvis", True)
|
|
741
|
+
if grid_vis:
|
|
742
|
+
building_height_grid_nan = building_height_grid.copy()
|
|
743
|
+
building_height_grid_nan[building_height_grid_nan == 0] = np.nan
|
|
744
|
+
visualize_numerical_grid(np.flipud(building_height_grid_nan), meshsize, "building height (m)", cmap='viridis', label='Value')
|
|
745
|
+
|
|
746
|
+
# Save building data to GeoJSON
|
|
747
|
+
if not building_gdf.empty:
|
|
748
|
+
save_path = f"{output_dir}/building.gpkg"
|
|
749
|
+
building_gdf.to_file(save_path, driver='GPKG')
|
|
750
|
+
|
|
751
|
+
# Get canopy height data
|
|
752
|
+
if canopy_height_source == "Static":
|
|
753
|
+
# Create canopy height grid with same shape as land cover grid
|
|
754
|
+
canopy_height_grid_comp = np.zeros_like(land_cover_grid, dtype=float)
|
|
755
|
+
|
|
756
|
+
# Set default static height for trees (20 meters is a typical average tree height)
|
|
757
|
+
static_tree_height = kwargs.get("static_tree_height", 10.0)
|
|
758
|
+
tree_mask = (land_cover_grid == 4)
|
|
759
|
+
|
|
760
|
+
# Set static height for tree cells
|
|
761
|
+
canopy_height_grid_comp[tree_mask] = static_tree_height
|
|
762
|
+
else:
|
|
763
|
+
canopy_height_grid_comp = get_canopy_height_grid(rectangle_vertices, meshsize, canopy_height_source, output_dir, **kwargs)
|
|
764
|
+
|
|
765
|
+
canopy_height_grid = create_vegetation_height_grid_from_gdf_polygon(vegetation_gdf, meshsize, rectangle_vertices)
|
|
766
|
+
mask = (canopy_height_grid == 0) & (canopy_height_grid_comp != 0)
|
|
767
|
+
canopy_height_grid[mask] = canopy_height_grid_comp[mask]
|
|
768
|
+
|
|
769
|
+
# Handle DEM - either flat or from source
|
|
770
|
+
if kwargs.pop('flat_dem', None):
|
|
771
|
+
dem_grid = np.zeros_like(land_cover_grid)
|
|
772
|
+
else:
|
|
773
|
+
dem_grid = create_dem_grid_from_gdf_polygon(terrain_gdf, meshsize, rectangle_vertices)
|
|
774
|
+
|
|
775
|
+
# Visualize grid if requested
|
|
776
|
+
grid_vis = kwargs.get("gridvis", True)
|
|
777
|
+
if grid_vis:
|
|
778
|
+
visualize_numerical_grid(np.flipud(dem_grid), meshsize, title='Digital Elevation Model', cmap='terrain', label='Elevation (m)')
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
# Apply minimum canopy height threshold if specified
|
|
782
|
+
min_canopy_height = kwargs.get("min_canopy_height")
|
|
783
|
+
if min_canopy_height is not None:
|
|
784
|
+
canopy_height_grid[canopy_height_grid < kwargs["min_canopy_height"]] = 0
|
|
785
|
+
|
|
786
|
+
# Remove objects near perimeter if specified
|
|
787
|
+
remove_perimeter_object = kwargs.get("remove_perimeter_object")
|
|
788
|
+
if (remove_perimeter_object is not None) and (remove_perimeter_object > 0):
|
|
789
|
+
# Calculate perimeter width based on grid dimensions
|
|
790
|
+
w_peri = int(remove_perimeter_object * building_height_grid.shape[0] + 0.5)
|
|
791
|
+
h_peri = int(remove_perimeter_object * building_height_grid.shape[1] + 0.5)
|
|
792
|
+
|
|
793
|
+
# Clear canopy heights in perimeter
|
|
794
|
+
canopy_height_grid[:w_peri, :] = canopy_height_grid[-w_peri:, :] = canopy_height_grid[:, :h_peri] = canopy_height_grid[:, -h_peri:] = 0
|
|
795
|
+
|
|
796
|
+
# Find building IDs in perimeter regions
|
|
797
|
+
ids1 = np.unique(building_id_grid[:w_peri, :][building_id_grid[:w_peri, :] > 0])
|
|
798
|
+
ids2 = np.unique(building_id_grid[-w_peri:, :][building_id_grid[-w_peri:, :] > 0])
|
|
799
|
+
ids3 = np.unique(building_id_grid[:, :h_peri][building_id_grid[:, :h_peri] > 0])
|
|
800
|
+
ids4 = np.unique(building_id_grid[:, -h_peri:][building_id_grid[:, -h_peri:] > 0])
|
|
801
|
+
remove_ids = np.concatenate((ids1, ids2, ids3, ids4))
|
|
802
|
+
|
|
803
|
+
# Remove buildings in perimeter
|
|
804
|
+
for remove_id in remove_ids:
|
|
805
|
+
positions = np.where(building_id_grid == remove_id)
|
|
806
|
+
building_height_grid[positions] = 0
|
|
807
|
+
building_min_height_grid[positions] = [[] for _ in range(len(building_min_height_grid[positions]))]
|
|
808
|
+
|
|
809
|
+
# Generate 3D voxel grid
|
|
810
|
+
voxcity_grid = create_3d_voxel(building_height_grid, building_min_height_grid, building_id_grid, land_cover_grid, dem_grid, canopy_height_grid, meshsize, land_cover_source)
|
|
811
|
+
|
|
812
|
+
return voxcity_grid, building_height_grid, building_min_height_grid, building_id_grid, canopy_height_grid, land_cover_grid, dem_grid, building_gdf
|
|
813
|
+
|
|
694
814
|
def replace_nan_in_nested(arr, replace_value=10.0):
|
|
695
815
|
"""Replace NaN values in a nested array structure with a specified value.
|
|
696
816
|
|
voxcity/geoprocessor/grid.py
CHANGED
|
@@ -579,7 +579,7 @@ def create_building_height_grid_from_gdf_polygon(
|
|
|
579
579
|
if pd.isna(min_height):
|
|
580
580
|
min_height = 0
|
|
581
581
|
is_inner = row.get('is_inner', False)
|
|
582
|
-
feature_id = row.get('id',
|
|
582
|
+
feature_id = row.get('id', idx_b)
|
|
583
583
|
|
|
584
584
|
# Fix invalid geometry (buffer(0) or simplify if needed)
|
|
585
585
|
# Doing this once per building avoids repeated overhead per cell.
|
|
@@ -1013,4 +1013,253 @@ def grid_to_point_geodataframe(grid_ori, rectangle_vertices, meshsize):
|
|
|
1013
1013
|
'value': values
|
|
1014
1014
|
}, crs=CRS.from_epsg(4326))
|
|
1015
1015
|
|
|
1016
|
-
return gdf
|
|
1016
|
+
return gdf
|
|
1017
|
+
|
|
1018
|
+
def create_vegetation_height_grid_from_gdf_polygon(veg_gdf, mesh_size, polygon):
|
|
1019
|
+
"""
|
|
1020
|
+
Create a vegetation height grid from a GeoDataFrame of vegetation polygons/objects
|
|
1021
|
+
within the bounding box of a given polygon, at a specified mesh spacing.
|
|
1022
|
+
Cells that intersect one or more vegetation polygons receive the
|
|
1023
|
+
(by default) maximum vegetation height among intersecting polygons.
|
|
1024
|
+
Cells that do not intersect any vegetation are set to 0.
|
|
1025
|
+
|
|
1026
|
+
Args:
|
|
1027
|
+
veg_gdf (GeoDataFrame): A GeoDataFrame containing vegetation features
|
|
1028
|
+
(usually polygons) with a 'height' column
|
|
1029
|
+
(or a similarly named attribute). Must be in
|
|
1030
|
+
EPSG:4326 or reprojectable to it.
|
|
1031
|
+
mesh_size (float): Desired grid spacing in meters.
|
|
1032
|
+
polygon (list or Polygon):
|
|
1033
|
+
- If a list of (lon, lat) coords, will be converted to a shapely Polygon
|
|
1034
|
+
in EPSG:4326.
|
|
1035
|
+
- If a shapely Polygon, it must be in or reprojectable to EPSG:4326.
|
|
1036
|
+
|
|
1037
|
+
Returns:
|
|
1038
|
+
np.ndarray: 2D array of vegetation height values covering the bounding box
|
|
1039
|
+
of the polygon. The array is indexed [row, col] from top row
|
|
1040
|
+
(north) to bottom row (south). Cells with no intersecting
|
|
1041
|
+
vegetation are set to 0.
|
|
1042
|
+
"""
|
|
1043
|
+
# ------------------------------------------------------------------------
|
|
1044
|
+
# 1. Ensure veg_gdf is in WGS84 (EPSG:4326)
|
|
1045
|
+
# ------------------------------------------------------------------------
|
|
1046
|
+
if veg_gdf.crs is None:
|
|
1047
|
+
warnings.warn("veg_gdf has no CRS. Assuming EPSG:4326. "
|
|
1048
|
+
"If this is incorrect, please set the correct CRS and re-run.")
|
|
1049
|
+
veg_gdf = veg_gdf.set_crs(epsg=4326)
|
|
1050
|
+
else:
|
|
1051
|
+
if veg_gdf.crs.to_epsg() != 4326:
|
|
1052
|
+
veg_gdf = veg_gdf.to_crs(epsg=4326)
|
|
1053
|
+
|
|
1054
|
+
# Must have a 'height' column (or change to your column name)
|
|
1055
|
+
if 'height' not in veg_gdf.columns:
|
|
1056
|
+
raise ValueError("Vegetation GeoDataFrame must have a 'height' column.")
|
|
1057
|
+
|
|
1058
|
+
# ------------------------------------------------------------------------
|
|
1059
|
+
# 2. Convert input polygon to shapely Polygon in WGS84
|
|
1060
|
+
# ------------------------------------------------------------------------
|
|
1061
|
+
if isinstance(polygon, list):
|
|
1062
|
+
poly = Polygon(polygon)
|
|
1063
|
+
elif isinstance(polygon, Polygon):
|
|
1064
|
+
poly = polygon
|
|
1065
|
+
else:
|
|
1066
|
+
raise ValueError("polygon must be a list of (lon, lat) or a shapely Polygon.")
|
|
1067
|
+
|
|
1068
|
+
# ------------------------------------------------------------------------
|
|
1069
|
+
# 3. Compute bounding box & grid dimensions
|
|
1070
|
+
# ------------------------------------------------------------------------
|
|
1071
|
+
left, bottom, right, top = poly.bounds
|
|
1072
|
+
geod = Geod(ellps="WGS84")
|
|
1073
|
+
|
|
1074
|
+
# Horizontal (width) distance in meters
|
|
1075
|
+
_, _, width_m = geod.inv(left, bottom, right, bottom)
|
|
1076
|
+
# Vertical (height) distance in meters
|
|
1077
|
+
_, _, height_m = geod.inv(left, bottom, left, top)
|
|
1078
|
+
|
|
1079
|
+
# Number of cells horizontally and vertically
|
|
1080
|
+
num_cells_x = int(width_m / mesh_size + 0.5)
|
|
1081
|
+
num_cells_y = int(height_m / mesh_size + 0.5)
|
|
1082
|
+
|
|
1083
|
+
if num_cells_x < 1 or num_cells_y < 1:
|
|
1084
|
+
warnings.warn("Polygon bounding box is smaller than mesh_size; returning empty array.")
|
|
1085
|
+
return np.array([])
|
|
1086
|
+
|
|
1087
|
+
# ------------------------------------------------------------------------
|
|
1088
|
+
# 4. Generate the grid (cell centers) covering the bounding box
|
|
1089
|
+
# ------------------------------------------------------------------------
|
|
1090
|
+
xs = np.linspace(left, right, num_cells_x)
|
|
1091
|
+
ys = np.linspace(top, bottom, num_cells_y) # top→bottom
|
|
1092
|
+
X, Y = np.meshgrid(xs, ys)
|
|
1093
|
+
|
|
1094
|
+
# Flatten these for convenience
|
|
1095
|
+
xs_flat = X.ravel()
|
|
1096
|
+
ys_flat = Y.ravel()
|
|
1097
|
+
|
|
1098
|
+
# Create cell-center points as a GeoDataFrame
|
|
1099
|
+
grid_points = gpd.GeoDataFrame(
|
|
1100
|
+
geometry=[Point(lon, lat) for lon, lat in zip(xs_flat, ys_flat)],
|
|
1101
|
+
crs="EPSG:4326"
|
|
1102
|
+
)
|
|
1103
|
+
|
|
1104
|
+
# ------------------------------------------------------------------------
|
|
1105
|
+
# 5. Spatial join (INTERSECTION) to find which vegetation objects each cell intersects
|
|
1106
|
+
# - We only fill the cell if the point is actually inside (or intersects) a vegetation polygon
|
|
1107
|
+
# If your data is more consistent with "contains" or "within", adjust the predicate accordingly.
|
|
1108
|
+
# ------------------------------------------------------------------------
|
|
1109
|
+
# NOTE:
|
|
1110
|
+
# * If your vegetation is polygons, "predicate='intersects'" or "contains"
|
|
1111
|
+
# can be used. Typically we check whether the cell center is inside the polygon.
|
|
1112
|
+
# * If your vegetation is a point layer, you might do "predicate='within'"
|
|
1113
|
+
# or similar. Adjust as needed.
|
|
1114
|
+
#
|
|
1115
|
+
# We'll do a left join so that unmatched cells remain in the result with NaN values.
|
|
1116
|
+
# Then we group by the index of the original grid_points to handle multiple intersects.
|
|
1117
|
+
# The 'index_right' is from the vegetation layer.
|
|
1118
|
+
# ------------------------------------------------------------------------
|
|
1119
|
+
|
|
1120
|
+
joined = gpd.sjoin(
|
|
1121
|
+
grid_points,
|
|
1122
|
+
veg_gdf[['height', 'geometry']],
|
|
1123
|
+
how='left',
|
|
1124
|
+
predicate='intersects'
|
|
1125
|
+
)
|
|
1126
|
+
|
|
1127
|
+
# Because one cell (row in grid_points) can intersect multiple polygons,
|
|
1128
|
+
# we need to aggregate them. We'll take the *maximum* height by default.
|
|
1129
|
+
joined_agg = (
|
|
1130
|
+
joined.groupby(joined.index) # group by the index from grid_points
|
|
1131
|
+
.agg({'height': 'max'}) # or 'mean' if you prefer an average
|
|
1132
|
+
)
|
|
1133
|
+
|
|
1134
|
+
# joined_agg is now a DataFrame with the same index as grid_points.
|
|
1135
|
+
# If a row didn't intersect any polygon, 'height' is NaN.
|
|
1136
|
+
|
|
1137
|
+
# ------------------------------------------------------------------------
|
|
1138
|
+
# 6. Build the 2D height array, initializing with zeros
|
|
1139
|
+
# ------------------------------------------------------------------------
|
|
1140
|
+
veg_grid = np.zeros((num_cells_y, num_cells_x), dtype=float)
|
|
1141
|
+
|
|
1142
|
+
# The row, col in the final array corresponds to how we built 'grid_points':
|
|
1143
|
+
# row = i // num_cells_x
|
|
1144
|
+
# col = i % num_cells_x
|
|
1145
|
+
for i, row_data in joined_agg.iterrows():
|
|
1146
|
+
if not np.isnan(row_data['height']): # Only set values for cells with vegetation
|
|
1147
|
+
row_idx = i // num_cells_x
|
|
1148
|
+
col_idx = i % num_cells_x
|
|
1149
|
+
veg_grid[row_idx, col_idx] = row_data['height']
|
|
1150
|
+
|
|
1151
|
+
# Result: row=0 is the top-most row, row=-1 is bottom.
|
|
1152
|
+
return np.flipud(veg_grid)
|
|
1153
|
+
|
|
1154
|
+
def create_dem_grid_from_gdf_polygon(terrain_gdf, mesh_size, polygon):
|
|
1155
|
+
"""
|
|
1156
|
+
Create a height grid from a terrain GeoDataFrame within the bounding box
|
|
1157
|
+
of the given polygon, using nearest-neighbor sampling of elevations.
|
|
1158
|
+
Edges of the bounding box will also receive a nearest elevation,
|
|
1159
|
+
so there should be no NaNs around edges if data coverage is sufficient.
|
|
1160
|
+
|
|
1161
|
+
Args:
|
|
1162
|
+
terrain_gdf (GeoDataFrame): A GeoDataFrame containing terrain features
|
|
1163
|
+
(points or centroids) with an 'elevation' column.
|
|
1164
|
+
Must be in EPSG:4326 or reprojectable to it.
|
|
1165
|
+
mesh_size (float): Desired grid spacing in meters.
|
|
1166
|
+
polygon (list or Polygon): Polygon specifying the region of interest.
|
|
1167
|
+
- If list of (lon, lat), will be made into a Polygon.
|
|
1168
|
+
- If a shapely Polygon, must be in WGS84 (EPSG:4326)
|
|
1169
|
+
or reprojected to it.
|
|
1170
|
+
|
|
1171
|
+
Returns:
|
|
1172
|
+
np.ndarray: 2D array of height values covering the bounding box of the polygon,
|
|
1173
|
+
from top row (north) to bottom row (south). Any location not
|
|
1174
|
+
matched by terrain_gdf data remains NaN, but edges will not
|
|
1175
|
+
automatically be NaN if terrain coverage exists.
|
|
1176
|
+
"""
|
|
1177
|
+
|
|
1178
|
+
# ------------------------------------------------------------------------
|
|
1179
|
+
# 1. Ensure terrain_gdf is in WGS84 (EPSG:4326)
|
|
1180
|
+
# ------------------------------------------------------------------------
|
|
1181
|
+
if terrain_gdf.crs is None:
|
|
1182
|
+
warnings.warn("terrain_gdf has no CRS. Assuming EPSG:4326. "
|
|
1183
|
+
"If this is incorrect, please set the correct CRS and re-run.")
|
|
1184
|
+
terrain_gdf = terrain_gdf.set_crs(epsg=4326)
|
|
1185
|
+
else:
|
|
1186
|
+
# Reproject if needed
|
|
1187
|
+
if terrain_gdf.crs.to_epsg() != 4326:
|
|
1188
|
+
terrain_gdf = terrain_gdf.to_crs(epsg=4326)
|
|
1189
|
+
|
|
1190
|
+
# Convert input polygon to shapely Polygon in WGS84
|
|
1191
|
+
if isinstance(polygon, list):
|
|
1192
|
+
poly = Polygon(polygon) # assume coords are (lon, lat) in EPSG:4326
|
|
1193
|
+
elif isinstance(polygon, Polygon):
|
|
1194
|
+
poly = polygon
|
|
1195
|
+
else:
|
|
1196
|
+
raise ValueError("`polygon` must be a list of (lon, lat) or a shapely Polygon.")
|
|
1197
|
+
|
|
1198
|
+
# ------------------------------------------------------------------------
|
|
1199
|
+
# 2. Compute bounding box and number of grid cells
|
|
1200
|
+
# ------------------------------------------------------------------------
|
|
1201
|
+
left, bottom, right, top = poly.bounds
|
|
1202
|
+
geod = Geod(ellps="WGS84")
|
|
1203
|
+
|
|
1204
|
+
# Geodesic distances in meters
|
|
1205
|
+
_, _, width_m = geod.inv(left, bottom, right, bottom)
|
|
1206
|
+
_, _, height_m = geod.inv(left, bottom, left, top)
|
|
1207
|
+
|
|
1208
|
+
# Number of cells in X and Y directions
|
|
1209
|
+
num_cells_x = int(width_m / mesh_size + 0.5)
|
|
1210
|
+
num_cells_y = int(height_m / mesh_size + 0.5)
|
|
1211
|
+
|
|
1212
|
+
if num_cells_x < 1 or num_cells_y < 1:
|
|
1213
|
+
warnings.warn("Polygon bounding box is smaller than mesh_size; returning empty array.")
|
|
1214
|
+
return np.array([])
|
|
1215
|
+
|
|
1216
|
+
# ------------------------------------------------------------------------
|
|
1217
|
+
# 3. Generate grid points covering the bounding box
|
|
1218
|
+
# (all points, not just inside the polygon)
|
|
1219
|
+
# ------------------------------------------------------------------------
|
|
1220
|
+
xs = np.linspace(left, right, num_cells_x)
|
|
1221
|
+
ys = np.linspace(top, bottom, num_cells_y) # top->bottom
|
|
1222
|
+
X, Y = np.meshgrid(xs, ys)
|
|
1223
|
+
|
|
1224
|
+
# Flatten for convenience
|
|
1225
|
+
xs_flat = X.ravel()
|
|
1226
|
+
ys_flat = Y.ravel()
|
|
1227
|
+
|
|
1228
|
+
# Create GeoDataFrame of all bounding-box points
|
|
1229
|
+
grid_points = gpd.GeoDataFrame(
|
|
1230
|
+
geometry=[Point(lon, lat) for lon, lat in zip(xs_flat, ys_flat)],
|
|
1231
|
+
crs="EPSG:4326"
|
|
1232
|
+
)
|
|
1233
|
+
|
|
1234
|
+
# ------------------------------------------------------------------------
|
|
1235
|
+
# 4. Nearest-neighbor join from terrain_gdf to grid points
|
|
1236
|
+
# ------------------------------------------------------------------------
|
|
1237
|
+
if 'elevation' not in terrain_gdf.columns:
|
|
1238
|
+
raise ValueError("terrain_gdf must have an 'elevation' column.")
|
|
1239
|
+
|
|
1240
|
+
# Nearest spatial join (requires GeoPandas >= 0.10)
|
|
1241
|
+
# This will assign each grid point the nearest terrain_gdf elevation.
|
|
1242
|
+
grid_points_elev = gpd.sjoin_nearest(
|
|
1243
|
+
grid_points,
|
|
1244
|
+
terrain_gdf[['elevation', 'geometry']],
|
|
1245
|
+
how="left",
|
|
1246
|
+
distance_col="dist_to_terrain"
|
|
1247
|
+
)
|
|
1248
|
+
|
|
1249
|
+
# ------------------------------------------------------------------------
|
|
1250
|
+
# 5. Build the final 2D height array
|
|
1251
|
+
# (rows: top->bottom, columns: left->right)
|
|
1252
|
+
# ------------------------------------------------------------------------
|
|
1253
|
+
dem_grid = np.full((num_cells_y, num_cells_x), np.nan, dtype=float)
|
|
1254
|
+
|
|
1255
|
+
# The index mapping of grid_points_elev is the same as grid_points, so:
|
|
1256
|
+
# row = i // num_cells_x, col = i % num_cells_x
|
|
1257
|
+
for i, elevation_val in zip(grid_points_elev.index, grid_points_elev['elevation']):
|
|
1258
|
+
row = i // num_cells_x
|
|
1259
|
+
col = i % num_cells_x
|
|
1260
|
+
dem_grid[row, col] = elevation_val # could be NaN if no data
|
|
1261
|
+
|
|
1262
|
+
# By default, row=0 is the "north/top" row, row=-1 is "south/bottom" row.
|
|
1263
|
+
# If you prefer the bottom row as index=0, you'd do: np.flipud(dem_grid)
|
|
1264
|
+
|
|
1265
|
+
return np.flipud(dem_grid)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: voxcity
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.1
|
|
4
4
|
Summary: voxcity is an easy and one-stop tool to output 3d city models for microclimate simulation by integrating multiple geospatial open-data
|
|
5
5
|
Author-email: Kunihiko Fujiwara <kunihiko@nus.edu.sg>
|
|
6
6
|
Maintainer-email: Kunihiko Fujiwara <kunihiko@nus.edu.sg>
|
|
@@ -53,6 +53,7 @@ Requires-Dist: joblib
|
|
|
53
53
|
Requires-Dist: trimesh
|
|
54
54
|
Requires-Dist: pyvista
|
|
55
55
|
Requires-Dist: IPython
|
|
56
|
+
Requires-Dist: lxml
|
|
56
57
|
Provides-Extra: dev
|
|
57
58
|
Requires-Dist: coverage; extra == "dev"
|
|
58
59
|
Requires-Dist: mypy; extra == "dev"
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
voxcity/__init__.py,sha256=el9v3gfybHOF_GUYPeSOqN0-vCrTW0eU1mcvi0sEfeU,252
|
|
2
|
-
voxcity/generator.py,sha256=
|
|
2
|
+
voxcity/generator.py,sha256=QTUhRiKyk2fZh8w1Jlv9foaXm3sQd-XojQghctUOKCI,42128
|
|
3
3
|
voxcity/downloader/__init__.py,sha256=OgGcGxOXF4tjcEL6DhOnt13DYPTvOigUelp5xIpTqM0,171
|
|
4
|
+
voxcity/downloader/citygml.py,sha256=phGxEz829mDGXEugs8rTQzARQy-_JyqfcDLMijR4DoM,24467
|
|
4
5
|
voxcity/downloader/eubucco.py,sha256=XCkkdEPNuWdrnuxzL80Ext37WsgiCiZGueb-aQV5rvI,14476
|
|
5
6
|
voxcity/downloader/gee.py,sha256=hEN5OvQAltORYnrlPbmYcDequ6lKLmwyTbNaCZ81Vj8,16089
|
|
6
7
|
voxcity/downloader/mbfp.py,sha256=pGJuXXLRuRedlORXfg8WlgAVwmKI30jxki9t-v5NejY,3972
|
|
@@ -15,7 +16,7 @@ voxcity/exporter/magicavoxel.py,sha256=Fsv7yGRXeKmp82xcG3rOb0t_HtoqltNq2tHl08xVl
|
|
|
15
16
|
voxcity/exporter/obj.py,sha256=0RBFPMKGRH6uNmCLIwAoYFko1bOZKtTSwg7QnoPMud0,21593
|
|
16
17
|
voxcity/geoprocessor/__init_.py,sha256=JzPVhhttxBWvaZ0IGX2w7OWL5bCo_TIvpHefWeNXruA,133
|
|
17
18
|
voxcity/geoprocessor/draw.py,sha256=8Em2NvazFpYfFJUqG9LofNXaxdghKLL_rNuztmPwn8Q,13911
|
|
18
|
-
voxcity/geoprocessor/grid.py,sha256=
|
|
19
|
+
voxcity/geoprocessor/grid.py,sha256=g1go8vvv2q4epnkZIN6CvWieSqxFWtLEYqX5CPw6vUo,56340
|
|
19
20
|
voxcity/geoprocessor/mesh.py,sha256=r3cRPLgpbhjwgESBemHWWJ5pEWl2KdkRhID6mdLhios,11171
|
|
20
21
|
voxcity/geoprocessor/network.py,sha256=opb_kpUCAxDd1qtrWPStqR5reYZtVe96XxazNSen7Lk,18851
|
|
21
22
|
voxcity/geoprocessor/polygon.py,sha256=8Vb2AbkpKYhq1kk2hQMc-gitmUo9pFIe910v4p1vP2g,37772
|
|
@@ -29,9 +30,9 @@ voxcity/utils/lc.py,sha256=RwPd-VY3POV3gTrBhM7TubgGb9MCd3nVah_G8iUEF7k,11562
|
|
|
29
30
|
voxcity/utils/material.py,sha256=Vt3IID5Ft54HNJcEC4zi31BCPqi_687X3CSp7rXaRVY,5907
|
|
30
31
|
voxcity/utils/visualization.py,sha256=SF8W7sqvBl3sZbB5noWCY9ic2D34Gq01VZYJ9NDNZ4Y,85237
|
|
31
32
|
voxcity/utils/weather.py,sha256=CFPtoqRTajwMRswswDChwQ3BW1cGsnA3orgWHgz7Ehg,26304
|
|
32
|
-
voxcity-0.
|
|
33
|
-
voxcity-0.
|
|
34
|
-
voxcity-0.
|
|
35
|
-
voxcity-0.
|
|
36
|
-
voxcity-0.
|
|
37
|
-
voxcity-0.
|
|
33
|
+
voxcity-0.5.1.dist-info/licenses/AUTHORS.rst,sha256=m82vkI5QokEGdcHof2OxK39lf81w1P58kG9ZNNAKS9U,175
|
|
34
|
+
voxcity-0.5.1.dist-info/licenses/LICENSE,sha256=s_jE1Df1nTPL4A_5GCGic5Zwex0CVaPKcAmSilxJPPE,1089
|
|
35
|
+
voxcity-0.5.1.dist-info/METADATA,sha256=h-EFKQhTaEr-JtJmYfC8a2Ju9smuat3Uew55UlIWdgI,25733
|
|
36
|
+
voxcity-0.5.1.dist-info/WHEEL,sha256=1tXe9gY0PYatrMPMDd6jXqjfpz_B-Wqm32CPfRC58XU,91
|
|
37
|
+
voxcity-0.5.1.dist-info/top_level.txt,sha256=00b2U-LKfDllt6RL1R33MXie5MvxzUFye0NGD96t_8I,8
|
|
38
|
+
voxcity-0.5.1.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|