voxcity 0.6.15__py3-none-any.whl → 0.7.0__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.
Files changed (78) hide show
  1. voxcity/__init__.py +14 -8
  2. voxcity/downloader/__init__.py +2 -1
  3. voxcity/downloader/citygml.py +32 -18
  4. voxcity/downloader/gba.py +210 -0
  5. voxcity/downloader/gee.py +5 -1
  6. voxcity/downloader/mbfp.py +1 -1
  7. voxcity/downloader/oemj.py +80 -8
  8. voxcity/downloader/osm.py +23 -7
  9. voxcity/downloader/overture.py +26 -1
  10. voxcity/downloader/utils.py +73 -73
  11. voxcity/errors.py +30 -0
  12. voxcity/exporter/__init__.py +13 -4
  13. voxcity/exporter/cityles.py +633 -535
  14. voxcity/exporter/envimet.py +728 -708
  15. voxcity/exporter/magicavoxel.py +334 -297
  16. voxcity/exporter/netcdf.py +238 -0
  17. voxcity/exporter/obj.py +1481 -655
  18. voxcity/generator/__init__.py +44 -0
  19. voxcity/generator/api.py +675 -0
  20. voxcity/generator/grids.py +379 -0
  21. voxcity/generator/io.py +94 -0
  22. voxcity/generator/pipeline.py +282 -0
  23. voxcity/generator/voxelizer.py +380 -0
  24. voxcity/geoprocessor/__init__.py +75 -6
  25. voxcity/geoprocessor/conversion.py +153 -0
  26. voxcity/geoprocessor/draw.py +62 -12
  27. voxcity/geoprocessor/heights.py +199 -0
  28. voxcity/geoprocessor/io.py +101 -0
  29. voxcity/geoprocessor/merge_utils.py +91 -0
  30. voxcity/geoprocessor/mesh.py +806 -790
  31. voxcity/geoprocessor/network.py +708 -679
  32. voxcity/geoprocessor/overlap.py +84 -0
  33. voxcity/geoprocessor/raster/__init__.py +82 -0
  34. voxcity/geoprocessor/raster/buildings.py +428 -0
  35. voxcity/geoprocessor/raster/canopy.py +258 -0
  36. voxcity/geoprocessor/raster/core.py +150 -0
  37. voxcity/geoprocessor/raster/export.py +93 -0
  38. voxcity/geoprocessor/raster/landcover.py +156 -0
  39. voxcity/geoprocessor/raster/raster.py +110 -0
  40. voxcity/geoprocessor/selection.py +85 -0
  41. voxcity/geoprocessor/utils.py +18 -14
  42. voxcity/models.py +113 -0
  43. voxcity/simulator/common/__init__.py +22 -0
  44. voxcity/simulator/common/geometry.py +98 -0
  45. voxcity/simulator/common/raytracing.py +450 -0
  46. voxcity/simulator/solar/__init__.py +43 -0
  47. voxcity/simulator/solar/integration.py +336 -0
  48. voxcity/simulator/solar/kernels.py +62 -0
  49. voxcity/simulator/solar/radiation.py +648 -0
  50. voxcity/simulator/solar/temporal.py +434 -0
  51. voxcity/simulator/view.py +36 -2286
  52. voxcity/simulator/visibility/__init__.py +29 -0
  53. voxcity/simulator/visibility/landmark.py +392 -0
  54. voxcity/simulator/visibility/view.py +508 -0
  55. voxcity/utils/logging.py +61 -0
  56. voxcity/utils/orientation.py +51 -0
  57. voxcity/utils/weather/__init__.py +26 -0
  58. voxcity/utils/weather/epw.py +146 -0
  59. voxcity/utils/weather/files.py +36 -0
  60. voxcity/utils/weather/onebuilding.py +486 -0
  61. voxcity/visualizer/__init__.py +24 -0
  62. voxcity/visualizer/builder.py +43 -0
  63. voxcity/visualizer/grids.py +141 -0
  64. voxcity/visualizer/maps.py +187 -0
  65. voxcity/visualizer/palette.py +228 -0
  66. voxcity/visualizer/renderer.py +928 -0
  67. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/METADATA +113 -36
  68. voxcity-0.7.0.dist-info/RECORD +77 -0
  69. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/WHEEL +1 -1
  70. voxcity/generator.py +0 -1137
  71. voxcity/geoprocessor/grid.py +0 -1568
  72. voxcity/geoprocessor/polygon.py +0 -1344
  73. voxcity/simulator/solar.py +0 -2329
  74. voxcity/utils/visualization.py +0 -2660
  75. voxcity/utils/weather.py +0 -817
  76. voxcity-0.6.15.dist-info/RECORD +0 -37
  77. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/AUTHORS.rst +0 -0
  78. {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/LICENSE +0 -0
@@ -1,1568 +0,0 @@
1
- """
2
- This module provides functions for creating and manipulating grids of building heights, land cover, and elevation data.
3
- It includes functionality for:
4
- - Grid creation and manipulation for various data types (buildings, land cover, elevation)
5
- - Coordinate transformations and spatial operations
6
- - Data interpolation and aggregation
7
- - Vector to raster conversion
8
- """
9
-
10
- import numpy as np
11
- import pandas as pd
12
- import os
13
- from shapely.geometry import Polygon, Point, MultiPolygon, box, mapping
14
- from scipy.ndimage import label, generate_binary_structure
15
- from pyproj import Geod, Transformer, CRS
16
- import rasterio
17
- from rasterio import features
18
- from rasterio.transform import from_bounds
19
- from affine import Affine
20
- import geopandas as gpd
21
- from collections import defaultdict
22
- from scipy.interpolate import griddata
23
- from shapely.errors import GEOSException
24
- from rtree import index
25
- import warnings
26
-
27
- from .utils import (
28
- initialize_geod,
29
- calculate_distance,
30
- normalize_to_one_meter,
31
- create_building_polygons,
32
- convert_format_lat_lon
33
- )
34
- from ..geoprocessor.polygon import (
35
- filter_buildings,
36
- extract_building_heights_from_geotiff,
37
- extract_building_heights_from_gdf,
38
- complement_building_heights_from_gdf,
39
- process_building_footprints_by_overlap
40
- )
41
- from ..utils.lc import (
42
- get_class_priority,
43
- create_land_cover_polygons,
44
- get_dominant_class,
45
- )
46
- from ..downloader.gee import (
47
- get_roi,
48
- save_geotiff_open_buildings_temporal
49
- )
50
-
51
- def apply_operation(arr, meshsize):
52
- """
53
- Applies a sequence of operations to an array based on a mesh size to normalize and discretize values.
54
-
55
- This function performs the following sequence of operations:
56
- 1. Divides array by mesh size to normalize values
57
- 2. Adds 0.5 to round values to nearest integer
58
- 3. Floors the result to get integer values
59
- 4. Scales back to original units by multiplying by mesh size
60
-
61
- Args:
62
- arr (numpy.ndarray): Input array to transform
63
- meshsize (float): Size of mesh to use for calculations
64
-
65
- Returns:
66
- numpy.ndarray: Transformed array after applying operations
67
-
68
- Example:
69
- >>> arr = np.array([1.2, 2.7, 3.4])
70
- >>> meshsize = 0.5
71
- >>> result = apply_operation(arr, meshsize)
72
- """
73
- # Divide array by mesh size to normalize values
74
- step1 = arr / meshsize
75
- # Add 0.5 to round values to nearest integer
76
- step2 = step1 + 0.5
77
- # Floor to get integer values
78
- step3 = np.floor(step2)
79
- # Scale back to original units
80
- return step3 * meshsize
81
-
82
- def translate_array(input_array, translation_dict):
83
- """
84
- Translates values in an array according to a dictionary mapping.
85
-
86
- This function creates a new array where each value from the input array
87
- is replaced by its corresponding value from the translation dictionary.
88
- Values not found in the dictionary are replaced with empty strings.
89
-
90
- Args:
91
- input_array (numpy.ndarray): Array containing values to translate
92
- translation_dict (dict): Dictionary mapping input values to output values
93
-
94
- Returns:
95
- numpy.ndarray: Array with translated values, with same shape as input array
96
-
97
- Example:
98
- >>> arr = np.array([[1, 2], [3, 4]])
99
- >>> trans_dict = {1: 'A', 2: 'B', 3: 'C', 4: 'D'}
100
- >>> result = translate_array(arr, trans_dict)
101
- >>> # result = array([['A', 'B'], ['C', 'D']], dtype=object)
102
- """
103
- # Create empty array of same shape that can hold objects (e.g. strings)
104
- translated_array = np.empty_like(input_array, dtype=object)
105
- # Iterate through array and replace values using dictionary
106
- for i in range(input_array.shape[0]):
107
- for j in range(input_array.shape[1]):
108
- value = input_array[i, j]
109
- # Use dict.get() to handle missing keys, defaulting to empty string
110
- translated_array[i, j] = translation_dict.get(value, '')
111
- return translated_array
112
-
113
- def group_and_label_cells(array):
114
- """
115
- Convert non-zero numbers in a 2D numpy array to sequential IDs starting from 1.
116
-
117
- This function creates a new array where all non-zero values are replaced with
118
- sequential IDs (1, 2, 3, etc.) while preserving zero values. This is useful
119
- for labeling distinct regions or features in a grid.
120
-
121
- Args:
122
- array (numpy.ndarray): Input 2D array with non-zero values to be labeled
123
-
124
- Returns:
125
- numpy.ndarray: Array with non-zero values converted to sequential IDs,
126
- maintaining the same shape as input array
127
-
128
- Example:
129
- >>> arr = np.array([[0, 5, 5], [0, 5, 8], [0, 0, 8]])
130
- >>> result = group_and_label_cells(arr)
131
- >>> # result = array([[0, 1, 1], [0, 1, 2], [0, 0, 2]])
132
- """
133
- # Create a copy to avoid modifying input
134
- result = array.copy()
135
-
136
- # Get sorted set of unique non-zero values
137
- unique_values = sorted(set(array.flatten()) - {0})
138
-
139
- # Create mapping from original values to sequential IDs (1, 2, 3, etc)
140
- value_to_id = {value: idx + 1 for idx, value in enumerate(unique_values)}
141
-
142
- # Replace each non-zero value with its new sequential ID
143
- for value in unique_values:
144
- result[array == value] = value_to_id[value]
145
-
146
- return result
147
-
148
- def process_grid_optimized(grid_bi, dem_grid):
149
- """
150
- Optimized version that computes per-building averages without allocating
151
- huge arrays when building IDs are large and sparse.
152
- """
153
- result = dem_grid.copy()
154
-
155
- # Only process if there are non-zero values
156
- if np.any(grid_bi != 0):
157
- # Convert to integer IDs (handle NaN for float arrays)
158
- if grid_bi.dtype.kind == 'f':
159
- grid_bi_int = np.nan_to_num(grid_bi, nan=0).astype(np.int64)
160
- else:
161
- grid_bi_int = grid_bi.astype(np.int64)
162
-
163
- # Work only on non-zero cells
164
- flat_ids = grid_bi_int.ravel()
165
- flat_dem = dem_grid.ravel()
166
- nz_mask = flat_ids != 0
167
- if np.any(nz_mask):
168
- ids_nz = flat_ids[nz_mask]
169
- vals_nz = flat_dem[nz_mask]
170
-
171
- # Densify IDs via inverse indices to avoid np.bincount on large max(id)
172
- unique_ids, inverse_idx = np.unique(ids_nz, return_inverse=True)
173
- sums = np.bincount(inverse_idx, weights=vals_nz)
174
- counts = np.bincount(inverse_idx)
175
- counts[counts == 0] = 1
176
- means = sums / counts
177
-
178
- # Scatter means back to result for non-zero cells
179
- result.ravel()[nz_mask] = means[inverse_idx]
180
-
181
- return result - np.min(result)
182
-
183
- def process_grid(grid_bi, dem_grid):
184
- """
185
- Safe version that tries optimization first, then falls back to original method.
186
- """
187
- try:
188
- # Try the optimized version first
189
- return process_grid_optimized(grid_bi, dem_grid)
190
- except Exception as e:
191
- print(f"Optimized process_grid failed: {e}, using original method")
192
- # Fall back to original implementation
193
- unique_ids = np.unique(grid_bi[grid_bi != 0])
194
- result = dem_grid.copy()
195
-
196
- for id_num in unique_ids:
197
- mask = (grid_bi == id_num)
198
- avg_value = np.mean(dem_grid[mask])
199
- result[mask] = avg_value
200
-
201
- return result - np.min(result)
202
- """
203
- Optimized version that avoids converting to Python lists.
204
- Works directly with numpy arrays.
205
- """
206
- if not isinstance(arr, np.ndarray):
207
- return arr
208
-
209
- # Create output array
210
- result = np.empty_like(arr, dtype=object)
211
-
212
- # Vectorized operation for empty cells
213
- for i in range(arr.shape[0]):
214
- for j in range(arr.shape[1]):
215
- cell = arr[i, j]
216
-
217
- if cell is None or (isinstance(cell, list) and len(cell) == 0):
218
- result[i, j] = []
219
- elif isinstance(cell, list):
220
- # Process list without converting entire array
221
- new_cell = []
222
- for segment in cell:
223
- if isinstance(segment, (list, np.ndarray)):
224
- # Use numpy operations where possible
225
- if isinstance(segment, np.ndarray):
226
- new_segment = np.where(np.isnan(segment), replace_value, segment).tolist()
227
- else:
228
- new_segment = [replace_value if (isinstance(v, float) and np.isnan(v)) else v for v in segment]
229
- new_cell.append(new_segment)
230
- else:
231
- new_cell.append(segment)
232
- result[i, j] = new_cell
233
- else:
234
- result[i, j] = cell
235
-
236
- return result
237
-
238
- def calculate_grid_size(side_1, side_2, u_vec, v_vec, meshsize):
239
- """
240
- Calculate grid size and adjusted mesh size based on input parameters.
241
-
242
- This function determines the number of grid cells needed in each direction and
243
- adjusts the mesh size to exactly fit the desired area. The calculation takes into
244
- account the input vectors and desired mesh size to ensure proper coverage.
245
-
246
- Args:
247
- side_1 (numpy.ndarray): First side vector defining the grid extent
248
- side_2 (numpy.ndarray): Second side vector defining the grid extent
249
- u_vec (numpy.ndarray): Unit vector in first direction
250
- v_vec (numpy.ndarray): Unit vector in second direction
251
- meshsize (float): Desired mesh size in the same units as the vectors
252
-
253
- Returns:
254
- tuple: A tuple containing:
255
- - grid_size (tuple of ints): Number of cells in each direction (nx, ny)
256
- - adjusted_mesh_size (tuple of floats): Actual mesh sizes that fit the area exactly
257
-
258
- Example:
259
- >>> side1 = np.array([100, 0]) # 100 units in x direction
260
- >>> side2 = np.array([0, 50]) # 50 units in y direction
261
- >>> u = np.array([1, 0]) # Unit vector in x
262
- >>> v = np.array([0, 1]) # Unit vector in y
263
- >>> mesh = 10 # Desired 10-unit mesh
264
- >>> grid_size, adj_mesh = calculate_grid_size(side1, side2, u, v, mesh)
265
- """
266
- # Calculate total side lengths in meters using the relationship between side vectors and unit vectors
267
- # u_vec and v_vec represent degrees per meter along each side direction
268
- dist_side_1_m = np.linalg.norm(side_1) / (np.linalg.norm(u_vec) + 1e-12)
269
- dist_side_2_m = np.linalg.norm(side_2) / (np.linalg.norm(v_vec) + 1e-12)
270
-
271
- # Calculate number of cells (nx along u, ny along v), rounding to nearest integer and ensuring at least 1
272
- grid_size_0 = max(1, int(dist_side_1_m / meshsize + 0.5))
273
- grid_size_1 = max(1, int(dist_side_2_m / meshsize + 0.5))
274
-
275
- # Adjust mesh sizes (in meters) to exactly fit the sides with the calculated number of cells
276
- adjusted_mesh_size_0 = dist_side_1_m / grid_size_0
277
- adjusted_mesh_size_1 = dist_side_2_m / grid_size_1
278
-
279
- return (grid_size_0, grid_size_1), (adjusted_mesh_size_0, adjusted_mesh_size_1)
280
-
281
- def create_coordinate_mesh(origin, grid_size, adjusted_meshsize, u_vec, v_vec):
282
- """
283
- Create a coordinate mesh based on input parameters.
284
-
285
- This function generates a 3D array representing a coordinate mesh, where each point
286
- in the mesh is calculated by adding scaled vectors to the origin point. The mesh
287
- is created using the specified grid size and adjusted mesh sizes.
288
-
289
- Args:
290
- origin (numpy.ndarray): Origin point coordinates (shape: (2,) or (3,))
291
- grid_size (tuple): Size of grid in each dimension (nx, ny)
292
- adjusted_meshsize (tuple): Adjusted mesh size in each dimension (dx, dy)
293
- u_vec (numpy.ndarray): Unit vector in first direction
294
- v_vec (numpy.ndarray): Unit vector in second direction
295
-
296
- Returns:
297
- numpy.ndarray: 3D array of shape (coord_dim, ny, nx) containing the coordinates
298
- of each point in the mesh. coord_dim is the same as the
299
- dimensionality of the input vectors.
300
-
301
- Example:
302
- >>> origin = np.array([0, 0])
303
- >>> grid_size = (5, 4)
304
- >>> mesh_size = (10, 10)
305
- >>> u = np.array([1, 0])
306
- >>> v = np.array([0, 1])
307
- >>> coords = create_coordinate_mesh(origin, grid_size, mesh_size, u, v)
308
- """
309
- # Create evenly spaced points along each axis
310
- x = np.linspace(0, grid_size[0], grid_size[0])
311
- y = np.linspace(0, grid_size[1], grid_size[1])
312
-
313
- # Create 2D coordinate grids
314
- xx, yy = np.meshgrid(x, y)
315
-
316
- # Calculate coordinates of each cell by adding scaled vectors
317
- cell_coords = origin[:, np.newaxis, np.newaxis] + \
318
- xx[np.newaxis, :, :] * adjusted_meshsize[0] * u_vec[:, np.newaxis, np.newaxis] + \
319
- yy[np.newaxis, :, :] * adjusted_meshsize[1] * v_vec[:, np.newaxis, np.newaxis]
320
-
321
- return cell_coords
322
-
323
- def create_cell_polygon(origin, i, j, adjusted_meshsize, u_vec, v_vec):
324
- """
325
- Create a polygon representing a grid cell.
326
-
327
- This function generates a rectangular polygon for a specific grid cell by calculating
328
- its four corners based on the cell indices and grid parameters. The polygon is
329
- created in counter-clockwise order starting from the bottom-left corner.
330
-
331
- Args:
332
- origin (numpy.ndarray): Origin point coordinates (shape: (2,) or (3,))
333
- i (int): Row index of the cell
334
- j (int): Column index of the cell
335
- adjusted_meshsize (tuple): Adjusted mesh size in each dimension (dx, dy)
336
- u_vec (numpy.ndarray): Unit vector in first direction
337
- v_vec (numpy.ndarray): Unit vector in second direction
338
-
339
- Returns:
340
- shapely.geometry.Polygon: Polygon representing the grid cell, with vertices
341
- ordered counter-clockwise from bottom-left
342
-
343
- Example:
344
- >>> origin = np.array([0, 0])
345
- >>> i, j = 1, 2 # Cell at row 1, column 2
346
- >>> mesh_size = (10, 10)
347
- >>> u = np.array([1, 0])
348
- >>> v = np.array([0, 1])
349
- >>> cell_poly = create_cell_polygon(origin, i, j, mesh_size, u, v)
350
- """
351
- # Calculate the four corners of the cell by adding scaled vectors
352
- bottom_left = origin + i * adjusted_meshsize[0] * u_vec + j * adjusted_meshsize[1] * v_vec
353
- bottom_right = origin + (i + 1) * adjusted_meshsize[0] * u_vec + j * adjusted_meshsize[1] * v_vec
354
- top_right = origin + (i + 1) * adjusted_meshsize[0] * u_vec + (j + 1) * adjusted_meshsize[1] * v_vec
355
- top_left = origin + i * adjusted_meshsize[0] * u_vec + (j + 1) * adjusted_meshsize[1] * v_vec
356
-
357
- # Create polygon from corners in counter-clockwise order
358
- return Polygon([bottom_left, bottom_right, top_right, top_left])
359
-
360
- def tree_height_grid_from_land_cover(land_cover_grid_ori):
361
- """
362
- Convert a land cover grid to a tree height grid.
363
-
364
- This function transforms a land cover classification grid into a grid of tree heights
365
- by mapping land cover classes to predefined tree heights. The function first flips
366
- the input grid vertically and adjusts class values, then applies a translation
367
- dictionary to convert classes to heights.
368
-
369
- Land cover class to tree height mapping:
370
- - Class 4 (Forest): 10m height
371
- - All other classes: 0m height
372
-
373
- Args:
374
- land_cover_grid_ori (numpy.ndarray): Original land cover grid with class values
375
-
376
- Returns:
377
- numpy.ndarray: Grid of tree heights in meters, with same dimensions as input
378
-
379
- Example:
380
- >>> lc_grid = np.array([[1, 4, 2], [4, 3, 4], [2, 1, 3]])
381
- >>> tree_heights = tree_height_grid_from_land_cover(lc_grid)
382
- >>> # Result: array([[0, 10, 0], [10, 0, 10], [0, 0, 0]])
383
- """
384
- # Flip array vertically and add 1 to all values
385
- land_cover_grid = np.flipud(land_cover_grid_ori) + 1
386
-
387
- # Define mapping from land cover classes to tree heights
388
- tree_translation_dict = {
389
- 1: 0, # No trees
390
- 2: 0, # No trees
391
- 3: 0, # No trees
392
- 4: 10, # Forest - 10m height
393
- 5: 0, # No trees
394
- 6: 0, # No trees
395
- 7: 0, # No trees
396
- 8: 0, # No trees
397
- 9: 0, # No trees
398
- 10: 0 # No trees
399
- }
400
-
401
- # Convert land cover classes to tree heights and flip back
402
- tree_height_grid = translate_array(np.flipud(land_cover_grid), tree_translation_dict).astype(int)
403
-
404
- return tree_height_grid
405
-
406
- def create_land_cover_grid_from_geotiff_polygon(tiff_path, mesh_size, land_cover_classes, polygon):
407
- """
408
- Create a land cover grid from a GeoTIFF file within a polygon boundary.
409
-
410
- Args:
411
- tiff_path (str): Path to GeoTIFF file
412
- mesh_size (float): Size of mesh cells
413
- land_cover_classes (dict): Dictionary mapping land cover classes
414
- polygon (list): List of polygon vertices
415
-
416
- Returns:
417
- numpy.ndarray: Grid of land cover classes within the polygon
418
- """
419
- with rasterio.open(tiff_path) as src:
420
- # Read RGB bands from GeoTIFF
421
- img = src.read((1,2,3))
422
- left, bottom, right, top = src.bounds
423
- src_crs = src.crs
424
-
425
- # Create a Shapely polygon from input coordinates
426
- poly = Polygon(polygon)
427
-
428
- # Get bounds of the polygon in WGS84 coordinates
429
- left_wgs84, bottom_wgs84, right_wgs84, top_wgs84 = poly.bounds
430
- # print(left, bottom, right, top)
431
-
432
- # Calculate width and height using geodesic calculations for accuracy
433
- geod = Geod(ellps="WGS84")
434
- _, _, width = geod.inv(left_wgs84, bottom_wgs84, right_wgs84, bottom_wgs84)
435
- _, _, height = geod.inv(left_wgs84, bottom_wgs84, left_wgs84, top_wgs84)
436
-
437
- # Calculate number of grid cells based on mesh size
438
- num_cells_x = int(width / mesh_size + 0.5)
439
- num_cells_y = int(height / mesh_size + 0.5)
440
-
441
- # Adjust mesh_size to fit the image exactly
442
- adjusted_mesh_size_x = (right - left) / num_cells_x
443
- adjusted_mesh_size_y = (top - bottom) / num_cells_y
444
-
445
- # Create affine transform for mapping between pixel and world coordinates
446
- new_affine = Affine(adjusted_mesh_size_x, 0, left, 0, -adjusted_mesh_size_y, top)
447
-
448
- # Create coordinate grids for the new mesh
449
- cols, rows = np.meshgrid(np.arange(num_cells_x), np.arange(num_cells_y))
450
- xs, ys = new_affine * (cols, rows)
451
- xs_flat, ys_flat = xs.flatten(), ys.flatten()
452
-
453
- # Convert world coordinates to image pixel indices
454
- row, col = src.index(xs_flat, ys_flat)
455
- row, col = np.array(row), np.array(col)
456
-
457
- # Filter out indices that fall outside the image bounds
458
- valid = (row >= 0) & (row < src.height) & (col >= 0) & (col < src.width)
459
- row, col = row[valid], col[valid]
460
-
461
- # Initialize output grid with 'No Data' values
462
- grid = np.full((num_cells_y, num_cells_x), 'No Data', dtype=object)
463
-
464
- # Fill grid with dominant land cover classes
465
- for i, (r, c) in enumerate(zip(row, col)):
466
- cell_data = img[:, r, c]
467
- dominant_class = get_dominant_class(cell_data, land_cover_classes)
468
- grid_row, grid_col = np.unravel_index(i, (num_cells_y, num_cells_x))
469
- grid[grid_row, grid_col] = dominant_class
470
-
471
- # Flip grid vertically to match geographic orientation
472
- return np.flipud(grid)
473
-
474
- def create_land_cover_grid_from_gdf_polygon(gdf, meshsize, source, rectangle_vertices, default_class='Developed space'):
475
- """Create a grid of land cover classes from GeoDataFrame polygon data.
476
-
477
- Args:
478
- gdf (GeoDataFrame): GeoDataFrame containing land cover polygons
479
- meshsize (float): Size of each grid cell in meters
480
- source (str): Source of the land cover data to determine class priorities
481
- rectangle_vertices (list): List of 4 (lon,lat) coordinate pairs defining the rectangle bounds
482
- default_class (str, optional): Default land cover class for cells with no intersecting polygons.
483
- Defaults to 'Developed space'.
484
-
485
- Returns:
486
- numpy.ndarray: 2D grid of land cover classes as strings
487
-
488
- The function creates a regular grid over the given rectangle area and determines the dominant
489
- land cover class for each cell based on polygon intersections. Classes are assigned based on
490
- priority rules and majority area coverage.
491
- """
492
-
493
- # Default priority mapping for land cover classes (lower number = higher priority)
494
- class_priority = {
495
- 'Bareland': 4,
496
- 'Rangeland': 6,
497
- 'Developed space': 8,
498
- 'Road': 1, # Roads have highest priority
499
- 'Tree': 7,
500
- 'Water': 3,
501
- 'Agriculture land': 5,
502
- 'Building': 2 # Buildings have second highest priority
503
- }
504
-
505
- # Get source-specific priority mapping if available
506
- class_priority = get_class_priority(source)
507
-
508
- # Calculate grid dimensions and normalize direction vectors
509
- geod = initialize_geod()
510
- vertex_0, vertex_1, vertex_3 = rectangle_vertices[0], rectangle_vertices[1], rectangle_vertices[3]
511
-
512
- # Calculate actual distances between vertices using geodesic calculations
513
- dist_side_1 = calculate_distance(geod, vertex_0[0], vertex_0[1], vertex_1[0], vertex_1[1])
514
- dist_side_2 = calculate_distance(geod, vertex_0[0], vertex_0[1], vertex_3[0], vertex_3[1])
515
-
516
- # Create vectors representing the sides of the rectangle
517
- side_1 = np.array(vertex_1) - np.array(vertex_0)
518
- side_2 = np.array(vertex_3) - np.array(vertex_0)
519
-
520
- # Normalize vectors to represent 1 meter in each direction
521
- u_vec = normalize_to_one_meter(side_1, dist_side_1)
522
- v_vec = normalize_to_one_meter(side_2, dist_side_2)
523
-
524
- origin = np.array(rectangle_vertices[0])
525
- grid_size, adjusted_meshsize = calculate_grid_size(side_1, side_2, u_vec, v_vec, meshsize)
526
-
527
- print(f"Adjusted mesh size: {adjusted_meshsize}")
528
-
529
- # Initialize grid with default land cover class
530
- grid = np.full(grid_size, default_class, dtype=object)
531
-
532
- # Calculate bounding box for spatial indexing
533
- extent = [min(coord[1] for coord in rectangle_vertices), max(coord[1] for coord in rectangle_vertices),
534
- min(coord[0] for coord in rectangle_vertices), max(coord[0] for coord in rectangle_vertices)]
535
- plotting_box = box(extent[2], extent[0], extent[3], extent[1])
536
-
537
- # Create spatial index for efficient polygon lookup
538
- land_cover_polygons = []
539
- idx = index.Index()
540
- for i, row in gdf.iterrows():
541
- polygon = row.geometry
542
- land_cover_class = row['class']
543
- land_cover_polygons.append((polygon, land_cover_class))
544
- idx.insert(i, polygon.bounds)
545
-
546
- # Iterate through each grid cell
547
- for i in range(grid_size[0]):
548
- for j in range(grid_size[1]):
549
- land_cover_class = default_class
550
- cell = create_cell_polygon(origin, i, j, adjusted_meshsize, u_vec, v_vec)
551
-
552
- # Check intersections with polygons that could overlap this cell
553
- for k in idx.intersection(cell.bounds):
554
- polygon, land_cover_class_temp = land_cover_polygons[k]
555
- try:
556
- if cell.intersects(polygon):
557
- intersection = cell.intersection(polygon)
558
- # If polygon covers more than 50% of cell, consider its land cover class
559
- if intersection.area > cell.area/2:
560
- rank = class_priority[land_cover_class]
561
- rank_temp = class_priority[land_cover_class_temp]
562
- # Update cell class if new class has higher priority (lower rank)
563
- if rank_temp < rank:
564
- land_cover_class = land_cover_class_temp
565
- grid[i, j] = land_cover_class
566
- except GEOSException as e:
567
- print(f"GEOS error at grid cell ({i}, {j}): {str(e)}")
568
- # Attempt to fix invalid polygon geometry
569
- try:
570
- fixed_polygon = polygon.buffer(0)
571
- if cell.intersects(fixed_polygon):
572
- intersection = cell.intersection(fixed_polygon)
573
- if intersection.area > cell.area/2:
574
- rank = class_priority[land_cover_class]
575
- rank_temp = class_priority[land_cover_class_temp]
576
- if rank_temp < rank:
577
- land_cover_class = land_cover_class_temp
578
- grid[i, j] = land_cover_class
579
- except Exception as fix_error:
580
- print(f"Failed to fix polygon at grid cell ({i}, {j}): {str(fix_error)}")
581
- continue
582
- return grid
583
-
584
- def create_height_grid_from_geotiff_polygon(tiff_path, mesh_size, polygon):
585
- """
586
- Create a height grid from a GeoTIFF file within a polygon boundary.
587
-
588
- Args:
589
- tiff_path (str): Path to GeoTIFF file
590
- mesh_size (float): Size of mesh cells
591
- polygon (list): List of polygon vertices
592
-
593
- Returns:
594
- numpy.ndarray: Grid of heights within the polygon
595
- """
596
- with rasterio.open(tiff_path) as src:
597
- # Read height data
598
- img = src.read(1)
599
- left, bottom, right, top = src.bounds
600
- src_crs = src.crs
601
-
602
- # Create polygon from input coordinates
603
- poly = Polygon(polygon)
604
-
605
- # Get polygon bounds in WGS84
606
- left_wgs84, bottom_wgs84, right_wgs84, top_wgs84 = poly.bounds
607
- # print(left, bottom, right, top)
608
- # print(left_wgs84, bottom_wgs84, right_wgs84, top_wgs84)
609
-
610
- # Calculate actual distances using geodesic methods
611
- geod = Geod(ellps="WGS84")
612
- _, _, width = geod.inv(left_wgs84, bottom_wgs84, right_wgs84, bottom_wgs84)
613
- _, _, height = geod.inv(left_wgs84, bottom_wgs84, left_wgs84, top_wgs84)
614
-
615
- # Calculate grid dimensions and adjust mesh size
616
- num_cells_x = int(width / mesh_size + 0.5)
617
- num_cells_y = int(height / mesh_size + 0.5)
618
-
619
- adjusted_mesh_size_x = (right - left) / num_cells_x
620
- adjusted_mesh_size_y = (top - bottom) / num_cells_y
621
-
622
- # Create affine transform for coordinate mapping
623
- new_affine = Affine(adjusted_mesh_size_x, 0, left, 0, -adjusted_mesh_size_y, top)
624
-
625
- # Generate coordinate grids
626
- cols, rows = np.meshgrid(np.arange(num_cells_x), np.arange(num_cells_y))
627
- xs, ys = new_affine * (cols, rows)
628
- xs_flat, ys_flat = xs.flatten(), ys.flatten()
629
-
630
- # Convert to image coordinates
631
- row, col = src.index(xs_flat, ys_flat)
632
- row, col = np.array(row), np.array(col)
633
-
634
- # Filter valid indices
635
- valid = (row >= 0) & (row < src.height) & (col >= 0) & (col < src.width)
636
- row, col = row[valid], col[valid]
637
-
638
- # Create output grid and fill with height values
639
- grid = np.full((num_cells_y, num_cells_x), np.nan)
640
- flat_indices = np.ravel_multi_index((row, col), img.shape)
641
- np.put(grid, np.ravel_multi_index((rows.flatten()[valid], cols.flatten()[valid]), grid.shape), img.flat[flat_indices])
642
-
643
- return np.flipud(grid)
644
-
645
- def create_building_height_grid_from_gdf_polygon(
646
- gdf,
647
- meshsize,
648
- rectangle_vertices,
649
- overlapping_footprint=False,
650
- gdf_comp=None,
651
- geotiff_path_comp=None,
652
- complement_building_footprints=None,
653
- complement_height=None
654
- ):
655
- """
656
- Create a building height grid from GeoDataFrame data within a polygon boundary.
657
-
658
- Args:
659
- gdf (geopandas.GeoDataFrame): GeoDataFrame containing building information
660
- meshsize (float): Size of mesh cells
661
- rectangle_vertices (list): List of rectangle vertices defining the boundary
662
- overlapping_footprint (bool): If True, use precise geometry-based processing for overlaps.
663
- If False, use faster rasterio-based approach.
664
- gdf_comp (geopandas.GeoDataFrame, optional): Complementary GeoDataFrame
665
- geotiff_path_comp (str, optional): Path to complementary GeoTIFF file
666
- complement_building_footprints (bool, optional): Whether to complement footprints
667
- complement_height (float, optional): Height value to use for buildings with height=0
668
-
669
- Returns:
670
- tuple: (building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings)
671
- - building_height_grid (numpy.ndarray): Grid of building heights
672
- - building_min_height_grid (numpy.ndarray): Grid of min building heights (list per cell)
673
- - building_id_grid (numpy.ndarray): Grid of building IDs
674
- - filtered_buildings (geopandas.GeoDataFrame): The buildings used (filtered_gdf)
675
- """
676
- # --------------------------------------------------------------------------
677
- # 1) COMMON INITIAL SETUP AND DATA FILTERING
678
- # --------------------------------------------------------------------------
679
- geod = initialize_geod()
680
- vertex_0, vertex_1, vertex_3 = rectangle_vertices[0], rectangle_vertices[1], rectangle_vertices[3]
681
-
682
- # Distances for each side
683
- dist_side_1 = calculate_distance(geod, vertex_0[0], vertex_0[1], vertex_1[0], vertex_1[1])
684
- dist_side_2 = calculate_distance(geod, vertex_0[0], vertex_0[1], vertex_3[0], vertex_3[1])
685
-
686
- # Normalized vectors
687
- side_1 = np.array(vertex_1) - np.array(vertex_0)
688
- side_2 = np.array(vertex_3) - np.array(vertex_0)
689
- u_vec = normalize_to_one_meter(side_1, dist_side_1)
690
- v_vec = normalize_to_one_meter(side_2, dist_side_2)
691
-
692
- # Grid parameters
693
- origin = np.array(rectangle_vertices[0])
694
- grid_size, adjusted_meshsize = calculate_grid_size(side_1, side_2, u_vec, v_vec, meshsize)
695
-
696
- # Filter the input GeoDataFrame by bounding box
697
- extent = [
698
- min(coord[1] for coord in rectangle_vertices),
699
- max(coord[1] for coord in rectangle_vertices),
700
- min(coord[0] for coord in rectangle_vertices),
701
- max(coord[0] for coord in rectangle_vertices)
702
- ]
703
- plotting_box = box(extent[2], extent[0], extent[3], extent[1])
704
- filtered_gdf = gdf[gdf.geometry.intersects(plotting_box)].copy()
705
-
706
- # Count buildings with height=0 or NaN
707
- zero_height_count = len(filtered_gdf[filtered_gdf['height'] == 0])
708
- nan_height_count = len(filtered_gdf[filtered_gdf['height'].isna()])
709
- print(f"{zero_height_count+nan_height_count} of the total {len(filtered_gdf)} building footprint from the base data source did not have height data.")
710
-
711
- # Optionally merge heights from complementary sources
712
- if gdf_comp is not None:
713
- filtered_gdf_comp = gdf_comp[gdf_comp.geometry.intersects(plotting_box)].copy()
714
- if complement_building_footprints:
715
- filtered_gdf = complement_building_heights_from_gdf(filtered_gdf, filtered_gdf_comp)
716
- else:
717
- filtered_gdf = extract_building_heights_from_gdf(filtered_gdf, filtered_gdf_comp)
718
- elif geotiff_path_comp:
719
- filtered_gdf = extract_building_heights_from_geotiff(geotiff_path_comp, filtered_gdf)
720
-
721
- # After filtering and complementing heights, process overlapping buildings
722
- filtered_gdf = process_building_footprints_by_overlap(filtered_gdf, overlap_threshold=0.5)
723
-
724
- # --------------------------------------------------------------------------
725
- # 2) BRANCH BASED ON OVERLAPPING_FOOTPRINT PARAMETER
726
- # --------------------------------------------------------------------------
727
-
728
- if overlapping_footprint:
729
- # Use precise geometry-based approach for better overlap handling
730
- return _process_with_geometry_intersection(
731
- filtered_gdf, grid_size, adjusted_meshsize, origin, u_vec, v_vec, complement_height
732
- )
733
- else:
734
- # Use faster rasterio-based approach
735
- return _process_with_rasterio(
736
- filtered_gdf, grid_size, adjusted_meshsize, origin, u_vec, v_vec,
737
- rectangle_vertices, complement_height
738
- )
739
-
740
-
741
- def _process_with_geometry_intersection(filtered_gdf, grid_size, adjusted_meshsize, origin, u_vec, v_vec, complement_height):
742
- """
743
- Process buildings using precise geometry intersection approach.
744
- Better for handling overlapping footprints but slower.
745
- """
746
- # Initialize output grids
747
- building_height_grid = np.zeros(grid_size)
748
- building_id_grid = np.zeros(grid_size)
749
-
750
- # Use a Python list-of-lists or object array for min_height tracking
751
- building_min_height_grid = np.empty(grid_size, dtype=object)
752
- for i in range(grid_size[0]):
753
- for j in range(grid_size[1]):
754
- building_min_height_grid[i, j] = []
755
-
756
- # --------------------------------------------------------------------------
757
- # PREPARE BUILDING POLYGONS & SPATIAL INDEX
758
- # --------------------------------------------------------------------------
759
- building_polygons = []
760
- for idx_b, row in filtered_gdf.iterrows():
761
- polygon = row.geometry
762
- height = row.get('height', None)
763
-
764
- # Replace height=0 with complement_height if specified
765
- if complement_height is not None and (height == 0 or height is None):
766
- height = complement_height
767
-
768
- min_height = row.get('min_height', 0)
769
- if pd.isna(min_height):
770
- min_height = 0
771
-
772
- is_inner = row.get('is_inner', False)
773
- feature_id = row.get('id', idx_b)
774
-
775
- # Fix invalid geometry
776
- if not polygon.is_valid:
777
- try:
778
- polygon = polygon.buffer(0)
779
- if not polygon.is_valid:
780
- polygon = polygon.simplify(1e-8)
781
- except Exception as e:
782
- pass
783
-
784
- bounding_box = polygon.bounds # (minx, miny, maxx, maxy)
785
- building_polygons.append((
786
- polygon, bounding_box, height, min_height, is_inner, feature_id
787
- ))
788
-
789
- # Build R-tree index using bounding boxes
790
- idx = index.Index()
791
- for i_b, (poly, bbox, _, _, _, _) in enumerate(building_polygons):
792
- idx.insert(i_b, bbox)
793
-
794
- # --------------------------------------------------------------------------
795
- # MAIN GRID LOOP WITH PRECISE INTERSECTION
796
- # --------------------------------------------------------------------------
797
- INTERSECTION_THRESHOLD = 0.3
798
-
799
- for i in range(grid_size[0]):
800
- for j in range(grid_size[1]):
801
- # Create the cell polygon once
802
- cell = create_cell_polygon(origin, i, j, adjusted_meshsize, u_vec, v_vec)
803
- if not cell.is_valid:
804
- cell = cell.buffer(0)
805
- cell_area = cell.area
806
-
807
- # Find possible intersections from the index
808
- potential = list(idx.intersection(cell.bounds))
809
- if not potential:
810
- continue
811
-
812
- # Sort buildings by height descending
813
- cell_buildings = []
814
- for k in potential:
815
- bpoly, bbox, height, minh, inr, fid = building_polygons[k]
816
- sort_val = height if (height is not None) else -float('inf')
817
- cell_buildings.append((k, bpoly, bbox, height, minh, inr, fid, sort_val))
818
- cell_buildings.sort(key=lambda x: x[-1], reverse=True)
819
-
820
- found_intersection = False
821
- all_zero_or_nan = True
822
-
823
- for (k, polygon, bbox, height, min_height, is_inner, feature_id, _) in cell_buildings:
824
- try:
825
- # Quick bounding-box check
826
- minx_p, miny_p, maxx_p, maxy_p = bbox
827
- minx_c, miny_c, maxx_c, maxy_c = cell.bounds
828
-
829
- # Overlap bounding box
830
- overlap_minx = max(minx_p, minx_c)
831
- overlap_miny = max(miny_p, miny_c)
832
- overlap_maxx = min(maxx_p, maxx_c)
833
- overlap_maxy = min(maxy_p, maxy_c)
834
-
835
- if (overlap_maxx <= overlap_minx) or (overlap_maxy <= overlap_miny):
836
- continue
837
-
838
- # Area of bounding-box intersection
839
- bbox_intersect_area = (overlap_maxx - overlap_minx) * (overlap_maxy - overlap_miny)
840
- if bbox_intersect_area < INTERSECTION_THRESHOLD * cell_area:
841
- continue
842
-
843
- # Ensure valid geometry
844
- if not polygon.is_valid:
845
- polygon = polygon.buffer(0)
846
-
847
- if cell.intersects(polygon):
848
- intersection = cell.intersection(polygon)
849
- inter_area = intersection.area
850
-
851
- # If the fraction of cell covered > threshold
852
- if (inter_area / cell_area) > INTERSECTION_THRESHOLD:
853
- found_intersection = True
854
-
855
- # If not an inner courtyard
856
- if not is_inner:
857
- building_min_height_grid[i, j].append([min_height, height])
858
- building_id_grid[i, j] = feature_id
859
-
860
- # Update building height if valid
861
- if (height is not None and not np.isnan(height) and height > 0):
862
- all_zero_or_nan = False
863
- current_height = building_height_grid[i, j]
864
-
865
- # Replace if we had 0, nan, or smaller height
866
- if (current_height == 0 or np.isnan(current_height) or current_height < height):
867
- building_height_grid[i, j] = height
868
- else:
869
- # Inner courtyards => override with 0
870
- building_min_height_grid[i, j] = [[0, 0]]
871
- building_height_grid[i, j] = 0
872
- found_intersection = True
873
- all_zero_or_nan = False
874
- break
875
-
876
- except (GEOSException, ValueError) as e:
877
- # Attempt fallback fix
878
- try:
879
- simplified_polygon = polygon.simplify(1e-8)
880
- if simplified_polygon.is_valid:
881
- intersection = cell.intersection(simplified_polygon)
882
- inter_area = intersection.area
883
- if (inter_area / cell_area) > INTERSECTION_THRESHOLD:
884
- found_intersection = True
885
- if not is_inner:
886
- building_min_height_grid[i, j].append([min_height, height])
887
- building_id_grid[i, j] = feature_id
888
- if (height is not None and not np.isnan(height) and height > 0):
889
- all_zero_or_nan = False
890
- if (building_height_grid[i, j] == 0 or
891
- np.isnan(building_height_grid[i, j]) or
892
- building_height_grid[i, j] < height):
893
- building_height_grid[i, j] = height
894
- else:
895
- building_min_height_grid[i, j] = [[0, 0]]
896
- building_height_grid[i, j] = 0
897
- found_intersection = True
898
- all_zero_or_nan = False
899
- break
900
- except Exception as fix_error:
901
- print(f"Failed to process cell ({i}, {j}) - Building {k}: {str(fix_error)}")
902
- continue
903
-
904
- # If we found intersecting buildings but all were zero/NaN, mark as NaN
905
- if found_intersection and all_zero_or_nan:
906
- building_height_grid[i, j] = np.nan
907
-
908
- return building_height_grid, building_min_height_grid, building_id_grid, filtered_gdf
909
-
910
-
911
- def _process_with_rasterio(filtered_gdf, grid_size, adjusted_meshsize, origin, u_vec, v_vec, rectangle_vertices, complement_height):
912
- """
913
- Process buildings using fast rasterio-based approach.
914
- Faster but less precise for overlapping footprints.
915
- """
916
- # Set up transform for rasterio using rotated basis defined by u_vec and v_vec
917
- # Step vectors in coordinate units (degrees) per cell
918
- u_step = adjusted_meshsize[0] * u_vec
919
- v_step = adjusted_meshsize[1] * v_vec
920
-
921
- # Define the top-left corner so that row=0 is the northern edge
922
- top_left = origin + grid_size[1] * v_step
923
-
924
- # Affine transform mapping (col, row) -> (x, y)
925
- # x = a*col + b*row + c ; y = d*col + e*row + f
926
- # col increases along u_step; row increases southward, hence -v_step
927
- transform = Affine(u_step[0], -v_step[0], top_left[0],
928
- u_step[1], -v_step[1], top_left[1])
929
-
930
- # Process buildings data
931
- filtered_gdf = filtered_gdf.copy()
932
- if complement_height is not None:
933
- mask = (filtered_gdf['height'] == 0) | (filtered_gdf['height'].isna())
934
- filtered_gdf.loc[mask, 'height'] = complement_height
935
-
936
- # Add missing columns with defaults
937
- filtered_gdf['min_height'] = 0
938
-
939
- if 'is_inner' not in filtered_gdf.columns:
940
- filtered_gdf['is_inner'] = False
941
-
942
- if 'id' not in filtered_gdf.columns:
943
- filtered_gdf['id'] = range(len(filtered_gdf))
944
-
945
- # Sort by height for proper layering
946
- regular_buildings = filtered_gdf[~filtered_gdf['is_inner']].copy()
947
- regular_buildings = regular_buildings.sort_values('height', ascending=True, na_position='first')
948
-
949
- # Temporary raster grids in rasterio's (rows=ny, cols=nx) order
950
- height_raster = np.zeros((grid_size[1], grid_size[0]), dtype=np.float64)
951
- id_raster = np.zeros((grid_size[1], grid_size[0]), dtype=np.float64)
952
-
953
- # Vectorized rasterization
954
- if len(regular_buildings) > 0:
955
- valid_buildings = regular_buildings[regular_buildings.geometry.is_valid].copy()
956
-
957
- if len(valid_buildings) > 0:
958
- # Height grid
959
- height_shapes = [(mapping(geom), height) for geom, height in
960
- zip(valid_buildings.geometry, valid_buildings['height'])
961
- if pd.notna(height) and height > 0]
962
-
963
- if height_shapes:
964
- height_raster = features.rasterize(
965
- height_shapes,
966
- out_shape=(grid_size[1], grid_size[0]),
967
- transform=transform,
968
- fill=0,
969
- dtype=np.float64
970
- )
971
-
972
- # ID grid
973
- id_shapes = [(mapping(geom), id_val) for geom, id_val in
974
- zip(valid_buildings.geometry, valid_buildings['id'])]
975
-
976
- if id_shapes:
977
- id_raster = features.rasterize(
978
- id_shapes,
979
- out_shape=(grid_size[1], grid_size[0]),
980
- transform=transform,
981
- fill=0,
982
- dtype=np.float64
983
- )
984
-
985
- # Handle inner courtyards
986
- inner_buildings = filtered_gdf[filtered_gdf['is_inner']].copy()
987
- if len(inner_buildings) > 0:
988
- inner_shapes = [(mapping(geom), 1) for geom in inner_buildings.geometry if geom.is_valid]
989
- if inner_shapes:
990
- inner_mask = features.rasterize(
991
- inner_shapes,
992
- out_shape=(grid_size[1], grid_size[0]),
993
- transform=transform,
994
- fill=0,
995
- dtype=np.uint8
996
- )
997
- height_raster[inner_mask > 0] = 0
998
- id_raster[inner_mask > 0] = 0
999
-
1000
- # Simplified min_height grid
1001
- building_min_height_grid = np.empty(grid_size, dtype=object)
1002
- min_heights_raster = np.zeros((grid_size[1], grid_size[0]), dtype=np.float64)
1003
-
1004
- if len(regular_buildings) > 0:
1005
- valid_buildings = regular_buildings[regular_buildings.geometry.is_valid].copy()
1006
- if len(valid_buildings) > 0:
1007
- min_height_shapes = [(mapping(geom), min_h) for geom, min_h in
1008
- zip(valid_buildings.geometry, valid_buildings['min_height'])
1009
- if pd.notna(min_h)]
1010
-
1011
- if min_height_shapes:
1012
- min_heights_raster = features.rasterize(
1013
- min_height_shapes,
1014
- out_shape=(grid_size[1], grid_size[0]),
1015
- transform=transform,
1016
- fill=0,
1017
- dtype=np.float64
1018
- )
1019
-
1020
- # Convert to list format (simplified)
1021
- # Convert raster (ny, nx) to internal orientation (nx, ny) with north-up
1022
- building_height_grid = np.flipud(height_raster).T
1023
- building_id_grid = np.flipud(id_raster).T
1024
- min_heights = np.flipud(min_heights_raster).T
1025
-
1026
- for i in range(grid_size[0]):
1027
- for j in range(grid_size[1]):
1028
- if building_height_grid[i, j] > 0:
1029
- building_min_height_grid[i, j] = [[min_heights[i, j], building_height_grid[i, j]]]
1030
- else:
1031
- building_min_height_grid[i, j] = []
1032
-
1033
- return building_height_grid, building_min_height_grid, building_id_grid, filtered_gdf
1034
-
1035
- def create_building_height_grid_from_open_building_temporal_polygon(meshsize, rectangle_vertices, output_dir):
1036
- """
1037
- Create a building height grid from OpenBuildings temporal data within a polygon.
1038
-
1039
- Args:
1040
- meshsize (float): Size of mesh cells
1041
- rectangle_vertices (list): List of rectangle vertices defining the boundary
1042
- output_dir (str): Directory to save intermediate GeoTIFF files
1043
-
1044
- Returns:
1045
- tuple: (building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings)
1046
- """
1047
- # Get region of interest from vertices
1048
- roi = get_roi(rectangle_vertices)
1049
-
1050
- # Create output directory and save intermediate GeoTIFF
1051
- os.makedirs(output_dir, exist_ok=True)
1052
- geotiff_path = os.path.join(output_dir, "building_height.tif")
1053
- save_geotiff_open_buildings_temporal(roi, geotiff_path)
1054
-
1055
- # Create height grid from GeoTIFF
1056
- building_height_grid = create_height_grid_from_geotiff_polygon(geotiff_path, meshsize, rectangle_vertices)
1057
-
1058
- # Initialize min height grid with appropriate height ranges
1059
- building_min_height_grid = np.empty(building_height_grid.shape, dtype=object)
1060
- for i in range(building_height_grid.shape[0]):
1061
- for j in range(building_height_grid.shape[1]):
1062
- if building_height_grid[i, j] <= 0:
1063
- building_min_height_grid[i, j] = []
1064
- else:
1065
- building_min_height_grid[i, j] = [[0, building_height_grid[i, j]]]
1066
-
1067
- # Create building ID grid with sequential numbering for non-zero heights
1068
- filtered_buildings = gpd.GeoDataFrame()
1069
- building_id_grid = np.zeros_like(building_height_grid, dtype=int)
1070
- non_zero_positions = np.nonzero(building_height_grid)
1071
- sequence = np.arange(1, len(non_zero_positions[0]) + 1)
1072
- building_id_grid[non_zero_positions] = sequence
1073
-
1074
- return building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings
1075
-
1076
- def create_dem_grid_from_geotiff_polygon(tiff_path, mesh_size, rectangle_vertices, dem_interpolation=False):
1077
- """
1078
- Create a Digital Elevation Model (DEM) grid from a GeoTIFF file within a polygon boundary.
1079
-
1080
- Args:
1081
- tiff_path (str): Path to GeoTIFF file
1082
- mesh_size (float): Size of mesh cells
1083
- rectangle_vertices (list): List of rectangle vertices defining the boundary
1084
- dem_interpolation (bool): Whether to use cubic interpolation for smoother results
1085
-
1086
- Returns:
1087
- numpy.ndarray: Grid of elevation values
1088
- """
1089
- # Convert vertex coordinates to lat/lon format
1090
- converted_coords = convert_format_lat_lon(rectangle_vertices)
1091
- roi_shapely = Polygon(converted_coords)
1092
-
1093
- with rasterio.open(tiff_path) as src:
1094
- # Read DEM data and handle no-data values
1095
- dem = src.read(1)
1096
- dem = np.where(dem < -1000, 0, dem) # Replace extreme negative values with 0
1097
- transform = src.transform
1098
- src_crs = src.crs
1099
-
1100
- # Handle coordinate system conversion
1101
- if src_crs.to_epsg() != 3857:
1102
- transformer_to_3857 = Transformer.from_crs(src_crs, CRS.from_epsg(3857), always_xy=True)
1103
- else:
1104
- transformer_to_3857 = lambda x, y: (x, y)
1105
-
1106
- # Transform ROI bounds to EPSG:3857 (Web Mercator)
1107
- roi_bounds = roi_shapely.bounds
1108
- roi_left, roi_bottom = transformer_to_3857.transform(roi_bounds[0], roi_bounds[1])
1109
- roi_right, roi_top = transformer_to_3857.transform(roi_bounds[2], roi_bounds[3])
1110
-
1111
- # Convert to WGS84 for accurate distance calculations
1112
- wgs84 = CRS.from_epsg(4326)
1113
- transformer_to_wgs84 = Transformer.from_crs(CRS.from_epsg(3857), wgs84, always_xy=True)
1114
- roi_left_wgs84, roi_bottom_wgs84 = transformer_to_wgs84.transform(roi_left, roi_bottom)
1115
- roi_right_wgs84, roi_top_wgs84 = transformer_to_wgs84.transform(roi_right, roi_top)
1116
-
1117
- # Calculate actual distances using geodesic methods
1118
- geod = Geod(ellps="WGS84")
1119
- _, _, roi_width_m = geod.inv(roi_left_wgs84, roi_bottom_wgs84, roi_right_wgs84, roi_bottom_wgs84)
1120
- _, _, roi_height_m = geod.inv(roi_left_wgs84, roi_bottom_wgs84, roi_left_wgs84, roi_top_wgs84)
1121
-
1122
- # Calculate grid dimensions
1123
- num_cells_x = int(roi_width_m / mesh_size + 0.5)
1124
- num_cells_y = int(roi_height_m / mesh_size + 0.5)
1125
-
1126
- # Create coordinate grid in EPSG:3857
1127
- x = np.linspace(roi_left, roi_right, num_cells_x, endpoint=False)
1128
- y = np.linspace(roi_top, roi_bottom, num_cells_y, endpoint=False)
1129
- xx, yy = np.meshgrid(x, y)
1130
-
1131
- # Transform original DEM coordinates to EPSG:3857
1132
- rows, cols = np.meshgrid(range(dem.shape[0]), range(dem.shape[1]), indexing='ij')
1133
- orig_x, orig_y = rasterio.transform.xy(transform, rows.ravel(), cols.ravel())
1134
- orig_x, orig_y = transformer_to_3857.transform(orig_x, orig_y)
1135
-
1136
- # Interpolate DEM values onto new grid
1137
- points = np.column_stack((orig_x, orig_y))
1138
- values = dem.ravel()
1139
- if dem_interpolation:
1140
- # Use cubic interpolation for smoother results
1141
- grid = griddata(points, values, (xx, yy), method='cubic')
1142
- else:
1143
- # Use nearest neighbor interpolation for raw data
1144
- grid = griddata(points, values, (xx, yy), method='nearest')
1145
-
1146
- return np.flipud(grid)
1147
-
1148
- def grid_to_geodataframe(grid_ori, rectangle_vertices, meshsize):
1149
- """
1150
- Converts a 2D grid to a GeoDataFrame with cell polygons and values.
1151
-
1152
- This function transforms a regular grid into a GeoDataFrame where each cell is
1153
- represented as a polygon. The transformation handles coordinate systems properly,
1154
- converting between WGS84 (EPSG:4326) and Web Mercator (EPSG:3857) for accurate
1155
- distance calculations.
1156
-
1157
- Args:
1158
- grid_ori (numpy.ndarray): 2D array containing grid values
1159
- rectangle_vertices (list): List of [lon, lat] coordinates defining area corners.
1160
- Should be in WGS84 (EPSG:4326) format.
1161
- meshsize (float): Size of each grid cell in meters
1162
-
1163
- Returns:
1164
- GeoDataFrame: A GeoDataFrame with columns:
1165
- - geometry: Polygon geometry of each grid cell in WGS84 (EPSG:4326)
1166
- - value: Value from the original grid
1167
-
1168
- Example:
1169
- >>> grid = np.array([[1, 2], [3, 4]])
1170
- >>> vertices = [[lon1, lat1], [lon2, lat2], [lon3, lat3], [lon4, lat4]]
1171
- >>> mesh_size = 100 # 100 meters
1172
- >>> gdf = grid_to_geodataframe(grid, vertices, mesh_size)
1173
-
1174
- Notes:
1175
- - The input grid is flipped vertically before processing to match geographic
1176
- orientation (north at top)
1177
- - The output GeoDataFrame uses WGS84 (EPSG:4326) coordinate system
1178
- """
1179
- grid = np.flipud(grid_ori.copy())
1180
-
1181
- # Extract bounds from rectangle vertices
1182
- min_lon = min(v[0] for v in rectangle_vertices)
1183
- max_lon = max(v[0] for v in rectangle_vertices)
1184
- min_lat = min(v[1] for v in rectangle_vertices)
1185
- max_lat = max(v[1] for v in rectangle_vertices)
1186
-
1187
- rows, cols = grid.shape
1188
-
1189
- # Set up transformers for accurate coordinate calculations
1190
- wgs84 = CRS.from_epsg(4326)
1191
- web_mercator = CRS.from_epsg(3857)
1192
- transformer_to_mercator = Transformer.from_crs(wgs84, web_mercator, always_xy=True)
1193
- transformer_to_wgs84 = Transformer.from_crs(web_mercator, wgs84, always_xy=True)
1194
-
1195
- # Convert bounds to Web Mercator for accurate distance calculations
1196
- min_x, min_y = transformer_to_mercator.transform(min_lon, min_lat)
1197
- max_x, max_y = transformer_to_mercator.transform(max_lon, max_lat)
1198
-
1199
- # Calculate cell sizes in Web Mercator coordinates
1200
- cell_size_x = (max_x - min_x) / cols
1201
- cell_size_y = (max_y - min_y) / rows
1202
-
1203
- # Create lists to store data
1204
- polygons = []
1205
- values = []
1206
-
1207
- # Create grid cells
1208
- for i in range(rows):
1209
- for j in range(cols):
1210
- # Calculate cell bounds in Web Mercator
1211
- cell_min_x = min_x + j * cell_size_x
1212
- cell_max_x = min_x + (j + 1) * cell_size_x
1213
- # Flip vertical axis since grid is stored with origin at top-left
1214
- cell_min_y = max_y - (i + 1) * cell_size_y
1215
- cell_max_y = max_y - i * cell_size_y
1216
-
1217
- # Convert cell corners back to WGS84
1218
- cell_min_lon, cell_min_lat = transformer_to_wgs84.transform(cell_min_x, cell_min_y)
1219
- cell_max_lon, cell_max_lat = transformer_to_wgs84.transform(cell_max_x, cell_max_y)
1220
-
1221
- # Create polygon for cell
1222
- cell_poly = box(cell_min_lon, cell_min_lat, cell_max_lon, cell_max_lat)
1223
-
1224
- polygons.append(cell_poly)
1225
- values.append(grid[i, j])
1226
-
1227
- # Create GeoDataFrame
1228
- gdf = gpd.GeoDataFrame({
1229
- 'geometry': polygons,
1230
- 'value': values
1231
- }, crs=CRS.from_epsg(4326))
1232
-
1233
- return gdf
1234
-
1235
- def grid_to_point_geodataframe(grid_ori, rectangle_vertices, meshsize):
1236
- """
1237
- Converts a 2D grid to a GeoDataFrame with point geometries at cell centers and values.
1238
-
1239
- This function transforms a regular grid into a GeoDataFrame where each cell is
1240
- represented by a point at its center. The transformation handles coordinate systems
1241
- properly, converting between WGS84 (EPSG:4326) and Web Mercator (EPSG:3857) for
1242
- accurate distance calculations.
1243
-
1244
- Args:
1245
- grid_ori (numpy.ndarray): 2D array containing grid values
1246
- rectangle_vertices (list): List of [lon, lat] coordinates defining area corners.
1247
- Should be in WGS84 (EPSG:4326) format.
1248
- meshsize (float): Size of each grid cell in meters
1249
-
1250
- Returns:
1251
- GeoDataFrame: A GeoDataFrame with columns:
1252
- - geometry: Point geometry at center of each grid cell in WGS84 (EPSG:4326)
1253
- - value: Value from the original grid
1254
-
1255
- Example:
1256
- >>> grid = np.array([[1, 2], [3, 4]])
1257
- >>> vertices = [[lon1, lat1], [lon2, lat2], [lon3, lat3], [lon4, lat4]]
1258
- >>> mesh_size = 100 # 100 meters
1259
- >>> gdf = grid_to_point_geodataframe(grid, vertices, mesh_size)
1260
-
1261
- Notes:
1262
- - The input grid is flipped vertically before processing to match geographic
1263
- orientation (north at top)
1264
- - The output GeoDataFrame uses WGS84 (EPSG:4326) coordinate system
1265
- - Points are placed at the center of each grid cell
1266
- """
1267
- grid = np.flipud(grid_ori.copy())
1268
-
1269
- # Extract bounds from rectangle vertices
1270
- min_lon = min(v[0] for v in rectangle_vertices)
1271
- max_lon = max(v[0] for v in rectangle_vertices)
1272
- min_lat = min(v[1] for v in rectangle_vertices)
1273
- max_lat = max(v[1] for v in rectangle_vertices)
1274
-
1275
- rows, cols = grid.shape
1276
-
1277
- # Set up transformers for accurate coordinate calculations
1278
- wgs84 = CRS.from_epsg(4326)
1279
- web_mercator = CRS.from_epsg(3857)
1280
- transformer_to_mercator = Transformer.from_crs(wgs84, web_mercator, always_xy=True)
1281
- transformer_to_wgs84 = Transformer.from_crs(web_mercator, wgs84, always_xy=True)
1282
-
1283
- # Convert bounds to Web Mercator for accurate distance calculations
1284
- min_x, min_y = transformer_to_mercator.transform(min_lon, min_lat)
1285
- max_x, max_y = transformer_to_mercator.transform(max_lon, max_lat)
1286
-
1287
- # Calculate cell sizes in Web Mercator coordinates
1288
- cell_size_x = (max_x - min_x) / cols
1289
- cell_size_y = (max_y - min_y) / rows
1290
-
1291
- # Create lists to store data
1292
- points = []
1293
- values = []
1294
-
1295
- # Create grid points at cell centers
1296
- for i in range(rows):
1297
- for j in range(cols):
1298
- # Calculate cell center in Web Mercator
1299
- cell_center_x = min_x + (j + 0.5) * cell_size_x
1300
- # Flip vertical axis since grid is stored with origin at top-left
1301
- cell_center_y = max_y - (i + 0.5) * cell_size_y
1302
-
1303
- # Convert cell center back to WGS84
1304
- center_lon, center_lat = transformer_to_wgs84.transform(cell_center_x, cell_center_y)
1305
-
1306
- # Create point for cell center
1307
- from shapely.geometry import Point
1308
- cell_point = Point(center_lon, center_lat)
1309
-
1310
- points.append(cell_point)
1311
- values.append(grid[i, j])
1312
-
1313
- # Create GeoDataFrame
1314
- gdf = gpd.GeoDataFrame({
1315
- 'geometry': points,
1316
- 'value': values
1317
- }, crs=CRS.from_epsg(4326))
1318
-
1319
- return gdf
1320
-
1321
- def create_vegetation_height_grid_from_gdf_polygon(veg_gdf, mesh_size, polygon):
1322
- """
1323
- Create a vegetation height grid from a GeoDataFrame of vegetation polygons/objects
1324
- within the bounding box of a given polygon, at a specified mesh spacing.
1325
- Cells that intersect one or more vegetation polygons receive the
1326
- (by default) maximum vegetation height among intersecting polygons.
1327
- Cells that do not intersect any vegetation are set to 0.
1328
-
1329
- Args:
1330
- veg_gdf (GeoDataFrame): A GeoDataFrame containing vegetation features
1331
- (usually polygons) with a 'height' column
1332
- (or a similarly named attribute). Must be in
1333
- EPSG:4326 or reprojectable to it.
1334
- mesh_size (float): Desired grid spacing in meters.
1335
- polygon (list or Polygon):
1336
- - If a list of (lon, lat) coords, will be converted to a shapely Polygon
1337
- in EPSG:4326.
1338
- - If a shapely Polygon, it must be in or reprojectable to EPSG:4326.
1339
-
1340
- Returns:
1341
- np.ndarray: 2D array of vegetation height values covering the bounding box
1342
- of the polygon. The array is indexed [row, col] from top row
1343
- (north) to bottom row (south). Cells with no intersecting
1344
- vegetation are set to 0.
1345
- """
1346
- # ------------------------------------------------------------------------
1347
- # 1. Ensure veg_gdf is in WGS84 (EPSG:4326)
1348
- # ------------------------------------------------------------------------
1349
- if veg_gdf.crs is None:
1350
- warnings.warn("veg_gdf has no CRS. Assuming EPSG:4326. "
1351
- "If this is incorrect, please set the correct CRS and re-run.")
1352
- veg_gdf = veg_gdf.set_crs(epsg=4326)
1353
- else:
1354
- if veg_gdf.crs.to_epsg() != 4326:
1355
- veg_gdf = veg_gdf.to_crs(epsg=4326)
1356
-
1357
- # Must have a 'height' column (or change to your column name)
1358
- if 'height' not in veg_gdf.columns:
1359
- raise ValueError("Vegetation GeoDataFrame must have a 'height' column.")
1360
-
1361
- # ------------------------------------------------------------------------
1362
- # 2. Convert input polygon to shapely Polygon in WGS84
1363
- # ------------------------------------------------------------------------
1364
- if isinstance(polygon, list):
1365
- poly = Polygon(polygon)
1366
- elif isinstance(polygon, Polygon):
1367
- poly = polygon
1368
- else:
1369
- raise ValueError("polygon must be a list of (lon, lat) or a shapely Polygon.")
1370
-
1371
- # ------------------------------------------------------------------------
1372
- # 3. Compute bounding box & grid dimensions
1373
- # ------------------------------------------------------------------------
1374
- left, bottom, right, top = poly.bounds
1375
- geod = Geod(ellps="WGS84")
1376
-
1377
- # Horizontal (width) distance in meters
1378
- _, _, width_m = geod.inv(left, bottom, right, bottom)
1379
- # Vertical (height) distance in meters
1380
- _, _, height_m = geod.inv(left, bottom, left, top)
1381
-
1382
- # Number of cells horizontally and vertically
1383
- num_cells_x = int(width_m / mesh_size + 0.5)
1384
- num_cells_y = int(height_m / mesh_size + 0.5)
1385
-
1386
- if num_cells_x < 1 or num_cells_y < 1:
1387
- warnings.warn("Polygon bounding box is smaller than mesh_size; returning empty array.")
1388
- return np.array([])
1389
-
1390
- # ------------------------------------------------------------------------
1391
- # 4. Generate the grid (cell centers) covering the bounding box
1392
- # ------------------------------------------------------------------------
1393
- xs = np.linspace(left, right, num_cells_x)
1394
- ys = np.linspace(top, bottom, num_cells_y) # top→bottom
1395
- X, Y = np.meshgrid(xs, ys)
1396
-
1397
- # Flatten these for convenience
1398
- xs_flat = X.ravel()
1399
- ys_flat = Y.ravel()
1400
-
1401
- # Create cell-center points as a GeoDataFrame
1402
- grid_points = gpd.GeoDataFrame(
1403
- geometry=[Point(lon, lat) for lon, lat in zip(xs_flat, ys_flat)],
1404
- crs="EPSG:4326"
1405
- )
1406
-
1407
- # ------------------------------------------------------------------------
1408
- # 5. Spatial join (INTERSECTION) to find which vegetation objects each cell intersects
1409
- # - We only fill the cell if the point is actually inside (or intersects) a vegetation polygon
1410
- # If your data is more consistent with "contains" or "within", adjust the predicate accordingly.
1411
- # ------------------------------------------------------------------------
1412
- # NOTE:
1413
- # * If your vegetation is polygons, "predicate='intersects'" or "contains"
1414
- # can be used. Typically we check whether the cell center is inside the polygon.
1415
- # * If your vegetation is a point layer, you might do "predicate='within'"
1416
- # or similar. Adjust as needed.
1417
- #
1418
- # We'll do a left join so that unmatched cells remain in the result with NaN values.
1419
- # Then we group by the index of the original grid_points to handle multiple intersects.
1420
- # The 'index_right' is from the vegetation layer.
1421
- # ------------------------------------------------------------------------
1422
-
1423
- joined = gpd.sjoin(
1424
- grid_points,
1425
- veg_gdf[['height', 'geometry']],
1426
- how='left',
1427
- predicate='intersects'
1428
- )
1429
-
1430
- # Because one cell (row in grid_points) can intersect multiple polygons,
1431
- # we need to aggregate them. We'll take the *maximum* height by default.
1432
- joined_agg = (
1433
- joined.groupby(joined.index) # group by the index from grid_points
1434
- .agg({'height': 'max'}) # or 'mean' if you prefer an average
1435
- )
1436
-
1437
- # joined_agg is now a DataFrame with the same index as grid_points.
1438
- # If a row didn't intersect any polygon, 'height' is NaN.
1439
-
1440
- # ------------------------------------------------------------------------
1441
- # 6. Build the 2D height array, initializing with zeros
1442
- # ------------------------------------------------------------------------
1443
- veg_grid = np.zeros((num_cells_y, num_cells_x), dtype=float)
1444
-
1445
- # The row, col in the final array corresponds to how we built 'grid_points':
1446
- # row = i // num_cells_x
1447
- # col = i % num_cells_x
1448
- for i, row_data in joined_agg.iterrows():
1449
- if not np.isnan(row_data['height']): # Only set values for cells with vegetation
1450
- row_idx = i // num_cells_x
1451
- col_idx = i % num_cells_x
1452
- veg_grid[row_idx, col_idx] = row_data['height']
1453
-
1454
- # Result: row=0 is the top-most row, row=-1 is bottom.
1455
- return np.flipud(veg_grid)
1456
-
1457
- def create_dem_grid_from_gdf_polygon(terrain_gdf, mesh_size, polygon):
1458
- """
1459
- Create a height grid from a terrain GeoDataFrame within the bounding box
1460
- of the given polygon, using nearest-neighbor sampling of elevations.
1461
- Edges of the bounding box will also receive a nearest elevation,
1462
- so there should be no NaNs around edges if data coverage is sufficient.
1463
-
1464
- Args:
1465
- terrain_gdf (GeoDataFrame): A GeoDataFrame containing terrain features
1466
- (points or centroids) with an 'elevation' column.
1467
- Must be in EPSG:4326 or reprojectable to it.
1468
- mesh_size (float): Desired grid spacing in meters.
1469
- polygon (list or Polygon): Polygon specifying the region of interest.
1470
- - If list of (lon, lat), will be made into a Polygon.
1471
- - If a shapely Polygon, must be in WGS84 (EPSG:4326)
1472
- or reprojected to it.
1473
-
1474
- Returns:
1475
- np.ndarray: 2D array of height values covering the bounding box of the polygon,
1476
- from top row (north) to bottom row (south). Any location not
1477
- matched by terrain_gdf data remains NaN, but edges will not
1478
- automatically be NaN if terrain coverage exists.
1479
- """
1480
-
1481
- # ------------------------------------------------------------------------
1482
- # 1. Ensure terrain_gdf is in WGS84 (EPSG:4326)
1483
- # ------------------------------------------------------------------------
1484
- if terrain_gdf.crs is None:
1485
- warnings.warn("terrain_gdf has no CRS. Assuming EPSG:4326. "
1486
- "If this is incorrect, please set the correct CRS and re-run.")
1487
- terrain_gdf = terrain_gdf.set_crs(epsg=4326)
1488
- else:
1489
- # Reproject if needed
1490
- if terrain_gdf.crs.to_epsg() != 4326:
1491
- terrain_gdf = terrain_gdf.to_crs(epsg=4326)
1492
-
1493
- # Convert input polygon to shapely Polygon in WGS84
1494
- if isinstance(polygon, list):
1495
- poly = Polygon(polygon) # assume coords are (lon, lat) in EPSG:4326
1496
- elif isinstance(polygon, Polygon):
1497
- poly = polygon
1498
- else:
1499
- raise ValueError("`polygon` must be a list of (lon, lat) or a shapely Polygon.")
1500
-
1501
- # ------------------------------------------------------------------------
1502
- # 2. Compute bounding box and number of grid cells
1503
- # ------------------------------------------------------------------------
1504
- left, bottom, right, top = poly.bounds
1505
- geod = Geod(ellps="WGS84")
1506
-
1507
- # Geodesic distances in meters
1508
- _, _, width_m = geod.inv(left, bottom, right, bottom)
1509
- _, _, height_m = geod.inv(left, bottom, left, top)
1510
-
1511
- # Number of cells in X and Y directions
1512
- num_cells_x = int(width_m / mesh_size + 0.5)
1513
- num_cells_y = int(height_m / mesh_size + 0.5)
1514
-
1515
- if num_cells_x < 1 or num_cells_y < 1:
1516
- warnings.warn("Polygon bounding box is smaller than mesh_size; returning empty array.")
1517
- return np.array([])
1518
-
1519
- # ------------------------------------------------------------------------
1520
- # 3. Generate grid points covering the bounding box
1521
- # (all points, not just inside the polygon)
1522
- # ------------------------------------------------------------------------
1523
- xs = np.linspace(left, right, num_cells_x)
1524
- ys = np.linspace(top, bottom, num_cells_y) # top→bottom
1525
- X, Y = np.meshgrid(xs, ys)
1526
-
1527
- # Flatten for convenience
1528
- xs_flat = X.ravel()
1529
- ys_flat = Y.ravel()
1530
-
1531
- # Create GeoDataFrame of all bounding-box points
1532
- grid_points = gpd.GeoDataFrame(
1533
- geometry=[Point(lon, lat) for lon, lat in zip(xs_flat, ys_flat)],
1534
- crs="EPSG:4326"
1535
- )
1536
-
1537
- # ------------------------------------------------------------------------
1538
- # 4. Nearest-neighbor join from terrain_gdf to grid points
1539
- # ------------------------------------------------------------------------
1540
- if 'elevation' not in terrain_gdf.columns:
1541
- raise ValueError("terrain_gdf must have an 'elevation' column.")
1542
-
1543
- # Nearest spatial join (requires GeoPandas >= 0.10)
1544
- # This will assign each grid point the nearest terrain_gdf elevation.
1545
- grid_points_elev = gpd.sjoin_nearest(
1546
- grid_points,
1547
- terrain_gdf[['elevation', 'geometry']],
1548
- how="left",
1549
- distance_col="dist_to_terrain"
1550
- )
1551
-
1552
- # ------------------------------------------------------------------------
1553
- # 5. Build the final 2D height array
1554
- # (rows: top->bottom, columns: left->right)
1555
- # ------------------------------------------------------------------------
1556
- dem_grid = np.full((num_cells_y, num_cells_x), np.nan, dtype=float)
1557
-
1558
- # The index mapping of grid_points_elev is the same as grid_points, so:
1559
- # row = i // num_cells_x, col = i % num_cells_x
1560
- for i, elevation_val in zip(grid_points_elev.index, grid_points_elev['elevation']):
1561
- row = i // num_cells_x
1562
- col = i % num_cells_x
1563
- dem_grid[row, col] = elevation_val # could be NaN if no data
1564
-
1565
- # By default, row=0 is the "north/top" row, row=-1 is "south/bottom" row.
1566
- # If you prefer the bottom row as index=0, you'd do: np.flipud(dem_grid)
1567
-
1568
- return np.flipud(dem_grid)