voxcity 0.6.26__py3-none-any.whl → 1.0.2__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 (81) hide show
  1. voxcity/__init__.py +10 -4
  2. voxcity/downloader/__init__.py +2 -1
  3. voxcity/downloader/gba.py +210 -0
  4. voxcity/downloader/gee.py +5 -1
  5. voxcity/downloader/mbfp.py +1 -1
  6. voxcity/downloader/oemj.py +80 -8
  7. voxcity/downloader/utils.py +73 -73
  8. voxcity/errors.py +30 -0
  9. voxcity/exporter/__init__.py +9 -1
  10. voxcity/exporter/cityles.py +129 -34
  11. voxcity/exporter/envimet.py +51 -26
  12. voxcity/exporter/magicavoxel.py +42 -5
  13. voxcity/exporter/netcdf.py +27 -0
  14. voxcity/exporter/obj.py +103 -28
  15. voxcity/generator/__init__.py +47 -0
  16. voxcity/generator/api.py +721 -0
  17. voxcity/generator/grids.py +381 -0
  18. voxcity/generator/io.py +94 -0
  19. voxcity/generator/pipeline.py +282 -0
  20. voxcity/generator/update.py +429 -0
  21. voxcity/generator/voxelizer.py +392 -0
  22. voxcity/geoprocessor/__init__.py +75 -6
  23. voxcity/geoprocessor/conversion.py +153 -0
  24. voxcity/geoprocessor/draw.py +1488 -1169
  25. voxcity/geoprocessor/heights.py +199 -0
  26. voxcity/geoprocessor/io.py +101 -0
  27. voxcity/geoprocessor/merge_utils.py +91 -0
  28. voxcity/geoprocessor/mesh.py +26 -10
  29. voxcity/geoprocessor/network.py +35 -6
  30. voxcity/geoprocessor/overlap.py +84 -0
  31. voxcity/geoprocessor/raster/__init__.py +82 -0
  32. voxcity/geoprocessor/raster/buildings.py +435 -0
  33. voxcity/geoprocessor/raster/canopy.py +258 -0
  34. voxcity/geoprocessor/raster/core.py +150 -0
  35. voxcity/geoprocessor/raster/export.py +93 -0
  36. voxcity/geoprocessor/raster/landcover.py +159 -0
  37. voxcity/geoprocessor/raster/raster.py +110 -0
  38. voxcity/geoprocessor/selection.py +85 -0
  39. voxcity/geoprocessor/utils.py +824 -820
  40. voxcity/models.py +113 -0
  41. voxcity/simulator/common/__init__.py +22 -0
  42. voxcity/simulator/common/geometry.py +98 -0
  43. voxcity/simulator/common/raytracing.py +450 -0
  44. voxcity/simulator/solar/__init__.py +66 -0
  45. voxcity/simulator/solar/integration.py +336 -0
  46. voxcity/simulator/solar/kernels.py +62 -0
  47. voxcity/simulator/solar/radiation.py +648 -0
  48. voxcity/simulator/solar/sky.py +668 -0
  49. voxcity/simulator/solar/temporal.py +792 -0
  50. voxcity/simulator/view.py +36 -2286
  51. voxcity/simulator/visibility/__init__.py +29 -0
  52. voxcity/simulator/visibility/landmark.py +392 -0
  53. voxcity/simulator/visibility/view.py +508 -0
  54. voxcity/utils/__init__.py +11 -0
  55. voxcity/utils/classes.py +194 -0
  56. voxcity/utils/lc.py +80 -39
  57. voxcity/utils/logging.py +61 -0
  58. voxcity/utils/orientation.py +51 -0
  59. voxcity/utils/shape.py +230 -0
  60. voxcity/utils/weather/__init__.py +26 -0
  61. voxcity/utils/weather/epw.py +146 -0
  62. voxcity/utils/weather/files.py +36 -0
  63. voxcity/utils/weather/onebuilding.py +486 -0
  64. voxcity/visualizer/__init__.py +24 -0
  65. voxcity/visualizer/builder.py +43 -0
  66. voxcity/visualizer/grids.py +141 -0
  67. voxcity/visualizer/maps.py +187 -0
  68. voxcity/visualizer/palette.py +228 -0
  69. voxcity/visualizer/renderer.py +1145 -0
  70. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/METADATA +162 -48
  71. voxcity-1.0.2.dist-info/RECORD +81 -0
  72. voxcity/generator.py +0 -1302
  73. voxcity/geoprocessor/grid.py +0 -1739
  74. voxcity/geoprocessor/polygon.py +0 -1344
  75. voxcity/simulator/solar.py +0 -2339
  76. voxcity/utils/visualization.py +0 -2849
  77. voxcity/utils/weather.py +0 -1038
  78. voxcity-0.6.26.dist-info/RECORD +0 -38
  79. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/WHEEL +0 -0
  80. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/licenses/AUTHORS.rst +0 -0
  81. {voxcity-0.6.26.dist-info → voxcity-1.0.2.dist-info}/licenses/LICENSE +0 -0
@@ -1,2849 +0,0 @@
1
- """
2
- VoxelCity Visualization Utilities
3
-
4
- This module provides comprehensive visualization tools for 3D voxel city data,
5
- including support for multiple color schemes, 3D plotting with matplotlib and plotly,
6
- grid visualization on basemaps, and mesh-based rendering with PyVista.
7
-
8
- The module handles various data types including:
9
- - Land cover classifications
10
- - Building heights and footprints
11
- - Digital elevation models (DEM)
12
- - Canopy heights
13
- - View indices (sky view factor, green view index)
14
- - Simulation results on building surfaces
15
-
16
- Key Features:
17
- - Multiple predefined color schemes for voxel visualization
18
- - 2D and 3D plotting capabilities
19
- - Interactive web maps with folium
20
- - Mesh export functionality (OBJ format)
21
- - Multi-view scene generation
22
- - Custom simulation result overlays
23
- """
24
-
25
- import numpy as np
26
- import matplotlib.pyplot as plt
27
- from mpl_toolkits.mplot3d import Axes3D
28
- from tqdm import tqdm
29
- import matplotlib.colors as mcolors
30
- from matplotlib.colors import ListedColormap, BoundaryNorm
31
- import matplotlib.cm as cm
32
- import contextily as ctx
33
- from shapely.geometry import Polygon
34
- import plotly.graph_objects as go
35
- from tqdm import tqdm
36
- import pyproj
37
- # import rasterio
38
- from pyproj import CRS
39
- # from shapely.geometry import box
40
- import seaborn as sns
41
- import random
42
- import folium
43
- import math
44
- import trimesh
45
- import pyvista as pv
46
- from IPython.display import display
47
- import os
48
- import sys
49
-
50
- # Import utility functions for land cover classification
51
- from .lc import get_land_cover_classes
52
- # from ..geo.geojson import filter_buildings
53
-
54
- # Import grid processing functions
55
- from ..geoprocessor.grid import (
56
- calculate_grid_size,
57
- create_coordinate_mesh,
58
- create_cell_polygon,
59
- grid_to_geodataframe
60
- )
61
-
62
- # Import geospatial utility functions
63
- from ..geoprocessor.utils import (
64
- initialize_geod,
65
- calculate_distance,
66
- normalize_to_one_meter,
67
- setup_transformer,
68
- transform_coords,
69
- )
70
-
71
- # Import mesh generation and export functions
72
- from ..geoprocessor.mesh import (
73
- create_voxel_mesh,
74
- create_sim_surface_mesh,
75
- create_city_meshes,
76
- export_meshes,
77
- save_obj_from_colored_mesh
78
- )
79
- # from ..exporter.obj import save_obj_from_colored_mesh
80
-
81
- # Import material property functions
82
- from .material import get_material_dict
83
-
84
- def get_voxel_color_map(color_scheme='default'):
85
- """
86
- Returns a color map for voxel visualization based on the specified color scheme.
87
-
88
- This function provides multiple predefined color schemes for visualizing voxel data.
89
- Each scheme maps voxel class IDs to RGB color values [0-255]. The class IDs follow
90
- a specific convention where negative values represent built environment elements
91
- and positive values represent natural/ground surface elements.
92
-
93
- Voxel Class ID Convention:
94
- -99: Void/empty space (black)
95
- -30: Landmark buildings (special highlighting)
96
- -17 to -11: Building materials (plaster, glass, stone, metal, concrete, wood, brick)
97
- -3: Generic building structures
98
- -2: Trees/vegetation (above ground)
99
- -1: Underground/subsurface
100
- 1-14: Ground surface land cover types (bareland, vegetation, water, etc.)
101
-
102
- Parameters:
103
- -----------
104
- color_scheme : str, optional
105
- The name of the color scheme to use. Available options:
106
-
107
- Basic Schemes:
108
- - 'default': Original balanced color scheme for general use
109
- - 'high_contrast': High contrast colors for better visibility
110
- - 'monochrome': Shades of blue for academic presentations
111
- - 'pastel': Softer, muted colors for aesthetic appeal
112
- - 'dark_mode': Darker colors for dark backgrounds
113
- - 'grayscale': Black and white gradient with color accents
114
- - 'white_mode': Light minimal palette for white backgrounds
115
-
116
- Thematic Schemes:
117
- - 'autumn': Warm reds, oranges, and browns
118
- - 'cool': Cool blues, purples, and cyans
119
- - 'earth_tones': Natural earth colors
120
- - 'vibrant': Very bright, saturated colors
121
-
122
- Stylistic Schemes:
123
- - 'cyberpunk': Neon-like purples, pinks, and blues
124
- - 'tropical': Vibrant greens, oranges, pinks (island vibes)
125
- - 'vintage': Muted, sepia-like tones
126
- - 'neon_dreams': Super-bright, nightclub neon palette
127
-
128
- Returns:
129
- --------
130
- dict
131
- A dictionary mapping voxel class IDs (int) to RGB color values (list of 3 ints [0-255])
132
-
133
- Examples:
134
- ---------
135
- >>> colors = get_voxel_color_map('default')
136
- >>> print(colors[-3]) # Building color
137
- [180, 187, 216]
138
-
139
- >>> colors = get_voxel_color_map('cyberpunk')
140
- >>> print(colors[9]) # Water color in cyberpunk scheme
141
- [51, 0, 102]
142
-
143
- Notes:
144
- ------
145
- - All color values are in RGB format with range [0, 255]
146
- - The 'default' scheme should not be modified to maintain consistency
147
- - Unknown color schemes will fall back to 'default' with a warning
148
- - Color schemes can be extended by adding new elif blocks
149
- """
150
- # ----------------------
151
- # DO NOT MODIFY DEFAULT
152
- # ----------------------
153
- if color_scheme == 'default':
154
- return {
155
- -99: [0, 0, 0], # void,
156
- -30: [255, 0, 102], # (Pink) 'Landmark',
157
- -17: [238, 242, 234], # (light gray) 'plaster',
158
- -16: [56, 78, 84], # (Dark blue) 'glass',
159
- -15: [147, 140, 114], # (Light brown) 'stone',
160
- -14: [139, 149, 159], # (Gray) 'metal',
161
- -13: [186, 187, 181], # (Gray) 'concrete',
162
- -12: [248, 166, 2], # (Orange) 'wood',
163
- -11: [81, 59, 56], # (Dark red) 'brick',
164
- -3: [180, 187, 216], # Building
165
- -2: [78, 99, 63], # Tree
166
- -1: [188, 143, 143], # Underground
167
- 1: [239, 228, 176], # 'Bareland (ground surface)',
168
- 2: [123, 130, 59], # 'Rangeland (ground surface)',
169
- 3: [97, 140, 86], # 'Shrub (ground surface)',
170
- 4: [112, 120, 56], # 'Agriculture land (ground surface)',
171
- 5: [116, 150, 66], # 'Tree (ground surface)',
172
- 6: [187, 204, 40], # 'Moss and lichen (ground surface)',
173
- 7: [77, 118, 99], # 'Wet land (ground surface)',
174
- 8: [22, 61, 51], # 'Mangrove (ground surface)',
175
- 9: [44, 66, 133], # 'Water (ground surface)',
176
- 10: [205, 215, 224], # 'Snow and ice (ground surface)',
177
- 11: [108, 119, 129], # 'Developed space (ground surface)',
178
- 12: [59, 62, 87], # 'Road (ground surface)',
179
- 13: [150, 166, 190], # 'Building (ground surface)'
180
- 14: [239, 228, 176], # 'No Data (ground surface)'
181
- }
182
-
183
- elif color_scheme == 'high_contrast':
184
- return {
185
- -99: [0, 0, 0], # void
186
- -30: [255, 0, 255], # (Bright Magenta) 'Landmark'
187
- -17: [255, 255, 255], # (Pure White) 'plaster'
188
- -16: [0, 0, 255], # (Bright Blue) 'glass'
189
- -15: [153, 76, 0], # (Dark Brown) 'stone'
190
- -14: [192, 192, 192], # (Silver) 'metal'
191
- -13: [128, 128, 128], # (Gray) 'concrete'
192
- -12: [255, 128, 0], # (Bright Orange) 'wood'
193
- -11: [153, 0, 0], # (Dark Red) 'brick'
194
- -3: [0, 255, 255], # (Cyan) Building
195
- -2: [0, 153, 0], # (Green) Tree
196
- -1: [204, 0, 102], # (Dark Pink) Underground
197
- 1: [255, 255, 153], # (Light Yellow) 'Bareland'
198
- 2: [102, 153, 0], # (Olive Green) 'Rangeland'
199
- 3: [0, 204, 0], # (Bright Green) 'Shrub'
200
- 4: [153, 204, 0], # (Yellowish Green) 'Agriculture land'
201
- 5: [0, 102, 0], # (Dark Green) 'Tree'
202
- 6: [204, 255, 51], # (Lime Green) 'Moss and lichen'
203
- 7: [0, 153, 153], # (Teal) 'Wet land'
204
- 8: [0, 51, 0], # (Very Dark Green) 'Mangrove'
205
- 9: [0, 102, 204], # (Bright Blue) 'Water'
206
- 10: [255, 255, 255], # (White) 'Snow and ice'
207
- 11: [76, 76, 76], # (Dark Gray) 'Developed space'
208
- 12: [0, 0, 0], # (Black) 'Road'
209
- 13: [102, 102, 255], # (Light Purple) 'Building'
210
- 14: [255, 204, 153], # (Light Orange) 'No Data'
211
- }
212
-
213
- elif color_scheme == 'monochrome':
214
- return {
215
- -99: [0, 0, 0], # void
216
- -30: [28, 28, 99], # 'Landmark'
217
- -17: [242, 242, 242], # 'plaster'
218
- -16: [51, 51, 153], # 'glass'
219
- -15: [102, 102, 204], # 'stone'
220
- -14: [153, 153, 204], # 'metal'
221
- -13: [204, 204, 230], # 'concrete'
222
- -12: [76, 76, 178], # 'wood'
223
- -11: [25, 25, 127], # 'brick'
224
- -3: [179, 179, 230], # Building
225
- -2: [51, 51, 153], # Tree
226
- -1: [102, 102, 178], # Underground
227
- 1: [230, 230, 255], # 'Bareland'
228
- 2: [128, 128, 204], # 'Rangeland'
229
- 3: [102, 102, 204], # 'Shrub'
230
- 4: [153, 153, 230], # 'Agriculture land'
231
- 5: [76, 76, 178], # 'Tree'
232
- 6: [204, 204, 255], # 'Moss and lichen'
233
- 7: [76, 76, 178], # 'Wet land'
234
- 8: [25, 25, 127], # 'Mangrove'
235
- 9: [51, 51, 204], # 'Water'
236
- 10: [242, 242, 255], # 'Snow and ice'
237
- 11: [128, 128, 178], # 'Developed space'
238
- 12: [51, 51, 127], # 'Road'
239
- 13: [153, 153, 204], # 'Building'
240
- 14: [230, 230, 255], # 'No Data'
241
- }
242
-
243
- elif color_scheme == 'pastel':
244
- return {
245
- -99: [0, 0, 0], # void
246
- -30: [255, 179, 217], # (Pastel Pink) 'Landmark'
247
- -17: [245, 245, 245], # (Off White) 'plaster'
248
- -16: [173, 196, 230], # (Pastel Blue) 'glass'
249
- -15: [222, 213, 196], # (Pastel Brown) 'stone'
250
- -14: [211, 219, 226], # (Pastel Gray) 'metal'
251
- -13: [226, 226, 226], # (Light Gray) 'concrete'
252
- -12: [255, 223, 179], # (Pastel Orange) 'wood'
253
- -11: [204, 168, 166], # (Pastel Red) 'brick'
254
- -3: [214, 217, 235], # (Pastel Purple) Building
255
- -2: [190, 207, 180], # (Pastel Green) Tree
256
- -1: [235, 204, 204], # (Pastel Pink) Underground
257
- 1: [250, 244, 227], # (Cream) 'Bareland'
258
- 2: [213, 217, 182], # (Pastel Olive) 'Rangeland'
259
- 3: [200, 226, 195], # (Pastel Green) 'Shrub'
260
- 4: [209, 214, 188], # (Pastel Yellow-Green) 'Agriculture land'
261
- 5: [195, 220, 168], # (Light Pastel Green) 'Tree'
262
- 6: [237, 241, 196], # (Pastel Yellow) 'Moss and lichen'
263
- 7: [180, 210, 205], # (Pastel Teal) 'Wet land'
264
- 8: [176, 196, 190], # (Darker Pastel Teal) 'Mangrove'
265
- 9: [188, 206, 235], # (Pastel Blue) 'Water'
266
- 10: [242, 245, 250], # (Light Blue-White) 'Snow and ice'
267
- 11: [209, 213, 219], # (Pastel Gray) 'Developed space'
268
- 12: [189, 190, 204], # (Pastel Blue-Gray) 'Road'
269
- 13: [215, 221, 232], # (Very Light Pastel Blue) 'Building'
270
- 14: [250, 244, 227], # (Cream) 'No Data'
271
- }
272
-
273
- elif color_scheme == 'dark_mode':
274
- return {
275
- -99: [0, 0, 0], # void
276
- -30: [153, 51, 102], # (Dark Pink) 'Landmark'
277
- -17: [76, 76, 76], # (Dark Gray) 'plaster'
278
- -16: [33, 46, 51], # (Very Dark Blue) 'glass'
279
- -15: [89, 84, 66], # (Very Dark Brown) 'stone'
280
- -14: [83, 89, 94], # (Dark Gray) 'metal'
281
- -13: [61, 61, 61], # (Dark Gray) 'concrete'
282
- -12: [153, 102, 0], # (Dark Orange) 'wood'
283
- -11: [51, 35, 33], # (Very Dark Red) 'brick'
284
- -3: [78, 82, 99], # (Dark Purple) Building
285
- -2: [46, 58, 37], # (Dark Green) Tree
286
- -1: [99, 68, 68], # (Dark Pink) Underground
287
- 1: [102, 97, 75], # (Dark Yellow) 'Bareland'
288
- 2: [61, 66, 31], # (Dark Olive) 'Rangeland'
289
- 3: [46, 77, 46], # (Dark Green) 'Shrub'
290
- 4: [56, 61, 28], # (Dark Yellow-Green) 'Agriculture land'
291
- 5: [54, 77, 31], # (Dark Green) 'Tree'
292
- 6: [89, 97, 20], # (Dark Yellow) 'Moss and lichen'
293
- 7: [38, 59, 49], # (Dark Teal) 'Wet land'
294
- 8: [16, 31, 26], # (Very Dark Green) 'Mangrove'
295
- 9: [22, 33, 66], # (Dark Blue) 'Water'
296
- 10: [82, 87, 92], # (Dark Blue-Gray) 'Snow and ice'
297
- 11: [46, 51, 56], # (Dark Gray) 'Developed space'
298
- 12: [25, 31, 43], # (Very Dark Blue) 'Road'
299
- 13: [56, 64, 82], # (Dark Blue-Gray) 'Building'
300
- 14: [102, 97, 75], # (Dark Yellow) 'No Data'
301
- }
302
-
303
- elif color_scheme == 'grayscale':
304
- return {
305
- -99: [0, 0, 0], # void (black)
306
- -30: [253, 231, 37], # (Pink) 'Landmark',
307
- -17: [240, 240, 240], # 'plaster'
308
- -16: [60, 60, 60], # 'glass'
309
- -15: [130, 130, 130], # 'stone'
310
- -14: [150, 150, 150], # 'metal'
311
- -13: [180, 180, 180], # 'concrete'
312
- -12: [170, 170, 170], # 'wood'
313
- -11: [70, 70, 70], # 'brick'
314
- -3: [190, 190, 190], # Building
315
- -2: [90, 90, 90], # Tree
316
- -1: [160, 160, 160], # Underground
317
- 1: [230, 230, 230], # 'Bareland'
318
- 2: [120, 120, 120], # 'Rangeland'
319
- 3: [110, 110, 110], # 'Shrub'
320
- 4: [115, 115, 115], # 'Agriculture land'
321
- 5: [100, 100, 100], # 'Tree'
322
- 6: [210, 210, 210], # 'Moss and lichen'
323
- 7: [95, 95, 95], # 'Wet land'
324
- 8: [40, 40, 40], # 'Mangrove'
325
- 9: [50, 50, 50], # 'Water'
326
- 10: [220, 220, 220], # 'Snow and ice'
327
- 11: [140, 140, 140], # 'Developed space'
328
- 12: [30, 30, 30], # 'Road'
329
- 13: [170, 170, 170], # 'Building'
330
- 14: [230, 230, 230], # 'No Data'
331
- }
332
-
333
- elif color_scheme == 'white_mode':
334
- return {
335
- -99: [0, 0, 0], # void (transparent in rendering)
336
- -30: [220, 80, 80], # subtle highlight for landmarks
337
- -17: [250, 250, 250], # plaster (near white)
338
- -16: [210, 225, 235], # glass (light blue-gray)
339
- -15: [230, 225, 215], # stone (warm light gray)
340
- -14: [225, 230, 235], # metal (cool light gray)
341
- -13: [236, 236, 236], # concrete (very light gray)
342
- -12: [245, 232, 210], # wood (light beige)
343
- -11: [235, 210, 205], # brick (light rose)
344
- -3: [225, 230, 240], # Building (soft blue-gray)
345
- -2: [190, 210, 190], # Tree (soft green)
346
- -1: [230, 215, 215], # Underground (soft pinkish)
347
- 1: [248, 245, 235], # Bareland
348
- 2: [225, 235, 215], # Rangeland
349
- 3: [220, 235, 220], # Shrub
350
- 4: [240, 235, 215], # Agriculture land
351
- 5: [210, 230, 210], # Tree (ground)
352
- 6: [245, 250, 235], # Moss and lichen
353
- 7: [220, 235, 230], # Wet land
354
- 8: [205, 215, 210], # Mangrove
355
- 9: [200, 220, 245], # Water (pale blue)
356
- 10: [252, 252, 252], # Snow and ice (almost white)
357
- 11: [230, 230, 230], # Developed space
358
- 12: [210, 210, 215], # Road (light neutral)
359
- 13: [230, 235, 240], # Building (ground surface)
360
- 14: [248, 245, 235], # No Data
361
- }
362
-
363
- elif color_scheme == 'autumn':
364
- return {
365
- -99: [0, 0, 0], # void
366
- -30: [227, 66, 52], # (Red) 'Landmark'
367
- -17: [250, 240, 230], # (Antique White) 'plaster'
368
- -16: [94, 33, 41], # (Dark Red) 'glass'
369
- -15: [160, 120, 90], # (Medium Brown) 'stone'
370
- -14: [176, 141, 87], # (Bronze) 'metal'
371
- -13: [205, 186, 150], # (Tan) 'concrete'
372
- -12: [204, 85, 0], # (Dark Orange) 'wood'
373
- -11: [128, 55, 36], # (Rust) 'brick'
374
- -3: [222, 184, 135], # (Tan) Building
375
- -2: [107, 68, 35], # (Brown) Tree
376
- -1: [165, 105, 79], # (Copper) Underground
377
- 1: [255, 235, 205], # (Blanched Almond) 'Bareland'
378
- 2: [133, 99, 99], # (Brown) 'Rangeland'
379
- 3: [139, 69, 19], # (Saddle Brown) 'Shrub'
380
- 4: [160, 82, 45], # (Sienna) 'Agriculture land'
381
- 5: [101, 67, 33], # (Dark Brown) 'Tree'
382
- 6: [255, 228, 196], # (Bisque) 'Moss and lichen'
383
- 7: [138, 51, 36], # (Rust) 'Wet land'
384
- 8: [85, 45, 23], # (Deep Brown) 'Mangrove'
385
- 9: [175, 118, 70], # (Light Brown) 'Water'
386
- 10: [255, 250, 240], # (Floral White) 'Snow and ice'
387
- 11: [188, 143, 143], # (Rosy Brown) 'Developed space'
388
- 12: [69, 41, 33], # (Very Dark Brown) 'Road'
389
- 13: [210, 180, 140], # (Tan) 'Building'
390
- 14: [255, 235, 205], # (Blanched Almond) 'No Data'
391
- }
392
-
393
- elif color_scheme == 'cool':
394
- return {
395
- -99: [0, 0, 0], # void
396
- -30: [180, 82, 205], # (Purple) 'Landmark'
397
- -17: [240, 248, 255], # (Alice Blue) 'plaster'
398
- -16: [70, 130, 180], # (Steel Blue) 'glass'
399
- -15: [100, 149, 237], # (Cornflower Blue) 'stone'
400
- -14: [176, 196, 222], # (Light Steel Blue) 'metal'
401
- -13: [240, 255, 255], # (Azure) 'concrete'
402
- -12: [65, 105, 225], # (Royal Blue) 'wood'
403
- -11: [95, 158, 160], # (Cadet Blue) 'brick'
404
- -3: [135, 206, 235], # (Sky Blue) Building
405
- -2: [0, 128, 128], # (Teal) Tree
406
- -1: [127, 255, 212], # (Aquamarine) Underground
407
- 1: [220, 240, 250], # (Light Blue) 'Bareland'
408
- 2: [72, 209, 204], # (Medium Turquoise) 'Rangeland'
409
- 3: [0, 191, 255], # (Deep Sky Blue) 'Shrub'
410
- 4: [100, 149, 237], # (Cornflower Blue) 'Agriculture land'
411
- 5: [0, 128, 128], # (Teal) 'Tree'
412
- 6: [175, 238, 238], # (Pale Turquoise) 'Moss and lichen'
413
- 7: [32, 178, 170], # (Light Sea Green) 'Wet land'
414
- 8: [25, 25, 112], # (Midnight Blue) 'Mangrove'
415
- 9: [30, 144, 255], # (Dodger Blue) 'Water'
416
- 10: [240, 255, 255], # (Azure) 'Snow and ice'
417
- 11: [119, 136, 153], # (Light Slate Gray) 'Developed space'
418
- 12: [25, 25, 112], # (Midnight Blue) 'Road'
419
- 13: [173, 216, 230], # (Light Blue) 'Building'
420
- 14: [220, 240, 250], # (Light Blue) 'No Data'
421
- }
422
-
423
- elif color_scheme == 'earth_tones':
424
- return {
425
- -99: [0, 0, 0], # void
426
- -30: [210, 105, 30], # (Chocolate) 'Landmark'
427
- -17: [245, 245, 220], # (Beige) 'plaster'
428
- -16: [139, 137, 137], # (Gray) 'glass'
429
- -15: [160, 120, 90], # (Medium Brown) 'stone'
430
- -14: [169, 169, 169], # (Dark Gray) 'metal'
431
- -13: [190, 190, 180], # (Light Gray-Tan) 'concrete'
432
- -12: [160, 82, 45], # (Sienna) 'wood'
433
- -11: [139, 69, 19], # (Saddle Brown) 'brick'
434
- -3: [210, 180, 140], # (Tan) Building
435
- -2: [85, 107, 47], # (Dark Olive Green) Tree
436
- -1: [133, 94, 66], # (Beaver) Underground
437
- 1: [222, 184, 135], # (Burlywood) 'Bareland'
438
- 2: [107, 142, 35], # (Olive Drab) 'Rangeland'
439
- 3: [85, 107, 47], # (Dark Olive Green) 'Shrub'
440
- 4: [128, 128, 0], # (Olive) 'Agriculture land'
441
- 5: [34, 139, 34], # (Forest Green) 'Tree'
442
- 6: [189, 183, 107], # (Dark Khaki) 'Moss and lichen'
443
- 7: [143, 188, 143], # (Dark Sea Green) 'Wet land'
444
- 8: [46, 139, 87], # (Sea Green) 'Mangrove'
445
- 9: [95, 158, 160], # (Cadet Blue) 'Water'
446
- 10: [238, 232, 205], # (Light Tan) 'Snow and ice'
447
- 11: [169, 169, 169], # (Dark Gray) 'Developed space'
448
- 12: [90, 90, 90], # (Dark Gray) 'Road'
449
- 13: [188, 170, 152], # (Tan) 'Building'
450
- 14: [222, 184, 135], # (Burlywood) 'No Data'
451
- }
452
-
453
- elif color_scheme == 'vibrant':
454
- return {
455
- -99: [0, 0, 0], # void
456
- -30: [255, 0, 255], # (Magenta) 'Landmark'
457
- -17: [255, 255, 255], # (White) 'plaster'
458
- -16: [0, 191, 255], # (Deep Sky Blue) 'glass'
459
- -15: [255, 215, 0], # (Gold) 'stone'
460
- -14: [0, 250, 154], # (Medium Spring Green) 'metal'
461
- -13: [211, 211, 211], # (Light Gray) 'concrete'
462
- -12: [255, 69, 0], # (Orange Red) 'wood'
463
- -11: [178, 34, 34], # (Firebrick) 'brick'
464
- -3: [123, 104, 238], # (Medium Slate Blue) Building
465
- -2: [50, 205, 50], # (Lime Green) Tree
466
- -1: [255, 20, 147], # (Deep Pink) Underground
467
- 1: [255, 255, 0], # (Yellow) 'Bareland'
468
- 2: [0, 255, 0], # (Lime) 'Rangeland'
469
- 3: [0, 128, 0], # (Green) 'Shrub'
470
- 4: [154, 205, 50], # (Yellow Green) 'Agriculture land'
471
- 5: [34, 139, 34], # (Forest Green) 'Tree'
472
- 6: [127, 255, 0], # (Chartreuse) 'Moss and lichen'
473
- 7: [64, 224, 208], # (Turquoise) 'Wet land'
474
- 8: [0, 100, 0], # (Dark Green) 'Mangrove'
475
- 9: [0, 0, 255], # (Blue) 'Water'
476
- 10: [240, 248, 255], # (Alice Blue) 'Snow and ice'
477
- 11: [128, 128, 128], # (Gray) 'Developed space'
478
- 12: [47, 79, 79], # (Dark Slate Gray) 'Road'
479
- 13: [135, 206, 250], # (Light Sky Blue) 'Building'
480
- 14: [255, 255, 224], # (Light Yellow) 'No Data'
481
- }
482
-
483
- # ------------------------------------------------
484
- # NEWLY ADDED STYLISH COLOR SCHEMES BELOW:
485
- # ------------------------------------------------
486
- elif color_scheme == 'cyberpunk':
487
- """
488
- Vibrant neon purples, pinks, and blues with deep blacks.
489
- Think futuristic city vibes and bright neon signs.
490
- """
491
- return {
492
- -99: [0, 0, 0], # void (keep it pitch black)
493
- -30: [255, 0, 255], # (Neon Magenta) 'Landmark'
494
- -17: [255, 255, 255], # (Bright White) 'plaster'
495
- -16: [0, 255, 255], # (Neon Cyan) 'glass'
496
- -15: [128, 0, 128], # (Purple) 'stone'
497
- -14: [50, 50, 50], # (Dark Gray) 'metal'
498
- -13: [102, 0, 102], # (Dark Magenta) 'concrete'
499
- -12: [255, 20, 147], # (Deep Pink) 'wood'
500
- -11: [153, 0, 76], # (Deep Purple-Red) 'brick'
501
- -3: [124, 0, 255], # (Strong Neon Purple) Building
502
- -2: [0, 255, 153], # (Neon Greenish Cyan) Tree
503
- -1: [255, 0, 102], # (Hot Pink) Underground
504
- 1: [255, 255, 153], # (Pale Yellow) 'Bareland'
505
- 2: [0, 204, 204], # (Teal) 'Rangeland'
506
- 3: [153, 51, 255], # (Light Purple) 'Shrub'
507
- 4: [0, 153, 255], # (Bright Neon Blue) 'Agriculture land'
508
- 5: [0, 255, 153], # (Neon Greenish Cyan) 'Tree'
509
- 6: [204, 0, 255], # (Vivid Violet) 'Moss and lichen'
510
- 7: [0, 255, 255], # (Neon Cyan) 'Wet land'
511
- 8: [0, 102, 102], # (Dark Teal) 'Mangrove'
512
- 9: [51, 0, 102], # (Deep Indigo) 'Water'
513
- 10: [255, 255, 255], # (White) 'Snow and ice'
514
- 11: [102, 102, 102], # (Gray) 'Developed space'
515
- 12: [0, 0, 0], # (Black) 'Road'
516
- 13: [204, 51, 255], # (Bright Magenta) 'Building'
517
- 14: [255, 255, 153], # (Pale Yellow) 'No Data'
518
- }
519
-
520
- elif color_scheme == 'tropical':
521
- """
522
- Bold, bright 'tropical vacation' color palette.
523
- Lots of greens, oranges, pinks, reminiscent of island florals.
524
- """
525
- return {
526
- -99: [0, 0, 0], # void
527
- -30: [255, 99, 164], # (Bright Tropical Pink) 'Landmark'
528
- -17: [255, 248, 220], # (Cornsilk) 'plaster'
529
- -16: [0, 150, 136], # (Teal) 'glass'
530
- -15: [255, 140, 0], # (Dark Orange) 'stone'
531
- -14: [255, 215, 180], # (Light Peach) 'metal'
532
- -13: [210, 210, 210], # (Light Gray) 'concrete'
533
- -12: [255, 165, 0], # (Orange) 'wood'
534
- -11: [205, 92, 92], # (Indian Red) 'brick'
535
- -3: [255, 193, 37], # (Tropical Yellow) Building
536
- -2: [34, 139, 34], # (Forest Green) Tree
537
- -1: [255, 160, 122], # (Light Salmon) Underground
538
- 1: [240, 230, 140], # (Khaki) 'Bareland'
539
- 2: [60, 179, 113], # (Medium Sea Green) 'Rangeland'
540
- 3: [46, 139, 87], # (Sea Green) 'Shrub'
541
- 4: [255, 127, 80], # (Coral) 'Agriculture land'
542
- 5: [50, 205, 50], # (Lime Green) 'Tree'
543
- 6: [255, 239, 213], # (Papaya Whip) 'Moss and lichen'
544
- 7: [255, 99, 71], # (Tomato) 'Wet land'
545
- 8: [47, 79, 79], # (Dark Slate Gray) 'Mangrove'
546
- 9: [0, 128, 128], # (Teal) 'Water'
547
- 10: [224, 255, 255], # (Light Cyan) 'Snow and ice'
548
- 11: [218, 112, 214], # (Orchid) 'Developed space'
549
- 12: [85, 107, 47], # (Dark Olive Green) 'Road'
550
- 13: [253, 245, 230], # (Old Lace) 'Building'
551
- 14: [240, 230, 140], # (Khaki) 'No Data'
552
- }
553
-
554
- elif color_scheme == 'vintage':
555
- """
556
- A muted, old-photo or sepia-inspired palette
557
- for a nostalgic or antique look.
558
- """
559
- return {
560
- -99: [0, 0, 0], # void
561
- -30: [133, 94, 66], # (Beaver/Brownish) 'Landmark'
562
- -17: [250, 240, 230], # (Antique White) 'plaster'
563
- -16: [169, 157, 143], # (Muted Brown-Gray) 'glass'
564
- -15: [181, 166, 127], # (Khaki Tan) 'stone'
565
- -14: [120, 106, 93], # (Faded Gray-Brown) 'metal'
566
- -13: [190, 172, 145], # (Light Brown) 'concrete'
567
- -12: [146, 109, 83], # (Leather Brown) 'wood'
568
- -11: [125, 80, 70], # (Dusty Brick) 'brick'
569
- -3: [201, 174, 146], # (Tanned Beige) Building
570
- -2: [112, 98, 76], # (Faded Olive-Brown) Tree
571
- -1: [172, 140, 114], # (Light Saddle Brown) Underground
572
- 1: [222, 202, 166], # (Light Tan) 'Bareland'
573
- 2: [131, 114, 83], # (Brownish) 'Rangeland'
574
- 3: [105, 96, 74], # (Dark Olive Brown) 'Shrub'
575
- 4: [162, 141, 118], # (Beige Brown) 'Agriculture land'
576
- 5: [95, 85, 65], # (Muted Dark Brown) 'Tree'
577
- 6: [212, 200, 180], # (Off-White Tan) 'Moss and lichen'
578
- 7: [140, 108, 94], # (Dusky Mauve-Brown) 'Wet land'
579
- 8: [85, 73, 60], # (Dark Taupe) 'Mangrove'
580
- 9: [166, 152, 121], # (Pale Brown) 'Water'
581
- 10: [250, 245, 235], # (Light Antique White) 'Snow and ice'
582
- 11: [120, 106, 93], # (Faded Gray-Brown) 'Developed space'
583
- 12: [77, 66, 55], # (Dark Taupe) 'Road'
584
- 13: [203, 188, 162], # (Light Warm Gray) 'Building'
585
- 14: [222, 202, 166], # (Light Tan) 'No Data'
586
- }
587
-
588
- elif color_scheme == 'neon_dreams':
589
- """
590
- A super-bright, high-energy neon palette.
591
- Perfect if you want a 'nightclub in 2080' vibe.
592
- """
593
- return {
594
- -99: [0, 0, 0], # void
595
- -30: [255, 0, 255], # (Magenta) 'Landmark'
596
- -17: [255, 255, 255], # (White) 'plaster'
597
- -16: [0, 255, 255], # (Cyan) 'glass'
598
- -15: [255, 255, 0], # (Yellow) 'stone'
599
- -14: [0, 255, 0], # (Lime) 'metal'
600
- -13: [128, 128, 128], # (Gray) 'concrete'
601
- -12: [255, 165, 0], # (Neon Orange) 'wood'
602
- -11: [255, 20, 147], # (Deep Pink) 'brick'
603
- -3: [75, 0, 130], # (Indigo) Building
604
- -2: [102, 255, 0], # (Bright Lime Green) Tree
605
- -1: [255, 51, 153], # (Neon Pink) Underground
606
- 1: [255, 153, 0], # (Bright Orange) 'Bareland'
607
- 2: [153, 204, 0], # (Vivid Yellow-Green) 'Rangeland'
608
- 3: [102, 205, 170], # (Aquamarine-ish) 'Shrub'
609
- 4: [0, 250, 154], # (Medium Spring Green) 'Agriculture land'
610
- 5: [173, 255, 47], # (Green-Yellow) 'Tree'
611
- 6: [127, 255, 0], # (Chartreuse) 'Moss and lichen'
612
- 7: [64, 224, 208], # (Turquoise) 'Wet land'
613
- 8: [0, 128, 128], # (Teal) 'Mangrove'
614
- 9: [0, 0, 255], # (Blue) 'Water'
615
- 10: [224, 255, 255], # (Light Cyan) 'Snow and ice'
616
- 11: [192, 192, 192], # (Silver) 'Developed space'
617
- 12: [25, 25, 25], # (Near Black) 'Road'
618
- 13: [75, 0, 130], # (Indigo) 'Building'
619
- 14: [255, 153, 0], # (Bright Orange) 'No Data'
620
- }
621
-
622
- else:
623
- # If an unknown color scheme is specified, return the default
624
- print(f"Unknown color scheme '{color_scheme}'. Using default instead.")
625
- return get_voxel_color_map('default')
626
-
627
- def plot_grid(grid, origin, adjusted_meshsize, u_vec, v_vec, transformer, vertices, data_type, vmin=None, vmax=None, color_map=None, alpha=0.5, buf=0.2, edge=True, basemap='CartoDB light', **kwargs):
628
- """
629
- Core function for plotting 2D grid data overlaid on basemaps.
630
-
631
- This function handles the visualization of various types of grid data by creating
632
- colored polygons for each grid cell and overlaying them on a web basemap. It supports
633
- different data types with appropriate color schemes and handles special values like
634
- NaN and zero appropriately.
635
-
636
- Parameters:
637
- -----------
638
- grid : numpy.ndarray
639
- 2D array containing the grid data values to visualize.
640
-
641
- origin : numpy.ndarray
642
- Geographic coordinates [lon, lat] of the grid's origin point.
643
-
644
- adjusted_meshsize : float
645
- Size of each grid cell in meters after grid size adjustments.
646
-
647
- u_vec, v_vec : numpy.ndarray
648
- Unit vectors defining the grid orientation in geographic space.
649
-
650
- transformer : pyproj.Transformer
651
- Coordinate transformer for converting between geographic and projected coordinates.
652
-
653
- vertices : list
654
- List of [lon, lat] coordinates defining the grid boundary.
655
-
656
- data_type : str
657
- Type of data being visualized. Supported types:
658
- - 'land_cover': Land use/land cover classifications
659
- - 'building_height': Building height values
660
- - 'dem': Digital elevation model
661
- - 'canopy_height': Vegetation height
662
- - 'green_view_index': Green visibility index
663
- - 'sky_view_index': Sky visibility factor
664
-
665
- vmin, vmax : float, optional
666
- Min/max values for color scaling. Auto-calculated if not provided.
667
-
668
- color_map : str, optional
669
- Matplotlib colormap name to override default schemes.
670
-
671
- alpha : float, optional
672
- Transparency of grid overlay (0-1). Default is 0.5.
673
-
674
- buf : float, optional
675
- Buffer around grid for plot extent as fraction of grid size. Default is 0.2.
676
-
677
- edge : bool, optional
678
- Whether to draw cell edges. Default is True.
679
-
680
- basemap : str, optional
681
- Basemap style name. Default is 'CartoDB light'.
682
-
683
- **kwargs : dict
684
- Additional parameters specific to data types:
685
- - land_cover_classes: Dictionary for land cover data
686
- - buildings: List of building polygons for building_height data
687
-
688
- Returns:
689
- --------
690
- None
691
- Displays the plot with matplotlib.
692
-
693
- Notes:
694
- ------
695
- - Grid is transposed to match geographic orientation
696
- - Special handling for NaN, zero, and negative values depending on data type
697
- - Basemap is added using contextily for geographic context
698
- - Plot extent is automatically calculated from grid vertices
699
- """
700
- # Create matplotlib figure and axis
701
- fig, ax = plt.subplots(figsize=(12, 12))
702
-
703
- # Configure visualization parameters based on data type
704
- if data_type == 'land_cover':
705
- # Land cover uses discrete color categories
706
- land_cover_classes = kwargs.get('land_cover_classes')
707
- colors = [mcolors.to_rgb(f'#{r:02x}{g:02x}{b:02x}') for r, g, b in land_cover_classes.keys()]
708
- cmap = mcolors.ListedColormap(colors)
709
- norm = mcolors.BoundaryNorm(range(len(land_cover_classes)+1), cmap.N)
710
- title = 'Grid Cells with Dominant Land Cover Classes'
711
- label = 'Land Cover Class'
712
- tick_labels = list(land_cover_classes.values())
713
-
714
- elif data_type == 'building_height':
715
- # Building height uses continuous colormap with special handling for zero/NaN
716
- # Create a masked array to handle special values
717
- masked_grid = np.ma.masked_array(grid, mask=(np.isnan(grid) | (grid == 0)))
718
-
719
- # Set up colormap and normalization for positive values
720
- cmap = plt.cm.viridis
721
- if vmin is None:
722
- vmin = np.nanmin(masked_grid[masked_grid > 0])
723
- if vmax is None:
724
- vmax = np.nanmax(masked_grid)
725
- norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
726
-
727
- title = 'Grid Cells with Building Heights'
728
- label = 'Building Height (m)'
729
- tick_labels = None
730
-
731
- elif data_type == 'dem':
732
- # Digital elevation model uses terrain colormap
733
- cmap = plt.cm.terrain
734
- if vmin is None:
735
- vmin = np.nanmin(grid)
736
- if vmax is None:
737
- vmax = np.nanmax(grid)
738
- norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
739
- title = 'DEM Grid Overlaid on Map'
740
- label = 'Elevation (m)'
741
- tick_labels = None
742
- elif data_type == 'canopy_height':
743
- cmap = plt.cm.Greens
744
- if vmin is None:
745
- vmin = np.nanmin(grid)
746
- if vmax is None:
747
- vmax = np.nanmax(grid)
748
- norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
749
- title = 'Canopy Height Grid Overlaid on Map'
750
- label = 'Canopy Height (m)'
751
- tick_labels = None
752
- elif data_type == 'green_view_index':
753
- cmap = plt.cm.Greens
754
- if vmin is None:
755
- vmin = np.nanmin(grid)
756
- if vmax is None:
757
- vmax = np.nanmax(grid)
758
- norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
759
- title = 'Green View Index Grid Overlaid on Map'
760
- label = 'Green View Index'
761
- tick_labels = None
762
- elif data_type == 'sky_view_index':
763
- cmap = plt.cm.get_cmap('BuPu_r').copy()
764
- if vmin is None:
765
- vmin = np.nanmin(grid)
766
- if vmax is None:
767
- vmax = np.nanmax(grid)
768
- norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
769
- title = 'Sky View Index Grid Overlaid on Map'
770
- label = 'Sky View Index'
771
- tick_labels = None
772
- else:
773
- cmap = plt.cm.viridis
774
- if vmin is None:
775
- vmin = np.nanmin(grid)
776
- if vmax is None:
777
- vmax = np.nanmax(grid)
778
- norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
779
- tick_labels = None
780
-
781
- if color_map:
782
- # cmap = plt.cm.get_cmap(color_map).copy()
783
- cmap = sns.color_palette(color_map, as_cmap=True).copy()
784
-
785
- # Ensure grid is in the correct orientation
786
- grid = grid.T
787
-
788
- for i in range(grid.shape[0]):
789
- for j in range(grid.shape[1]):
790
- cell = create_cell_polygon(origin, j, i, adjusted_meshsize, u_vec, v_vec) # Note the swap of i and j
791
- x, y = cell.exterior.xy
792
- x, y = zip(*[transformer.transform(lon, lat) for lat, lon in zip(x, y)])
793
-
794
- value = grid[i, j]
795
-
796
- if data_type == 'building_height':
797
- if np.isnan(value):
798
- # White fill for NaN values
799
- ax.fill(x, y, alpha=alpha, fc='gray', ec='black' if edge else None, linewidth=0.1)
800
- elif value == 0:
801
- # No fill for zero values, only edges if enabled
802
- if edge:
803
- ax.plot(x, y, color='black', linewidth=0.1)
804
- elif value > 0:
805
- # Viridis colormap for positive values
806
- color = cmap(norm(value))
807
- ax.fill(x, y, alpha=alpha, fc=color, ec='black' if edge else None, linewidth=0.1)
808
- elif data_type == 'canopy_height':
809
- color = cmap(norm(value))
810
- if value == 0:
811
- # No fill for zero values, only edges if enabled
812
- if edge:
813
- ax.plot(x, y, color='black', linewidth=0.1)
814
- else:
815
- if edge:
816
- ax.fill(x, y, alpha=alpha, fc=color, ec='black', linewidth=0.1)
817
- else:
818
- ax.fill(x, y, alpha=alpha, fc=color, ec=None)
819
- elif 'view' in data_type:
820
- if np.isnan(value):
821
- # No fill for zero values, only edges if enabled
822
- if edge:
823
- ax.plot(x, y, color='black', linewidth=0.1)
824
- elif value >= 0:
825
- # Viridis colormap for positive values
826
- color = cmap(norm(value))
827
- ax.fill(x, y, alpha=alpha, fc=color, ec='black' if edge else None, linewidth=0.1)
828
- else:
829
- color = cmap(norm(value))
830
- if edge:
831
- ax.fill(x, y, alpha=alpha, fc=color, ec='black', linewidth=0.1)
832
- else:
833
- ax.fill(x, y, alpha=alpha, fc=color, ec=None)
834
-
835
- crs_epsg_3857 = CRS.from_epsg(3857)
836
-
837
- basemaps = {
838
- 'CartoDB dark': ctx.providers.CartoDB.DarkMatter, # Popular dark option
839
- 'CartoDB light': ctx.providers.CartoDB.Positron, # Popular dark option
840
- 'CartoDB voyager': ctx.providers.CartoDB.Voyager, # Popular dark option
841
- 'CartoDB light no labels': ctx.providers.CartoDB.PositronNoLabels, # Popular dark option
842
- 'CartoDB dark no labels': ctx.providers.CartoDB.DarkMatterNoLabels,
843
- }
844
- ctx.add_basemap(ax, crs=crs_epsg_3857, source=basemaps[basemap])
845
- # if basemap == "dark":
846
- # ctx.add_basemap(ax, crs=crs_epsg_3857, source=ctx.providers.CartoDB.DarkMatter)
847
- # elif basemap == 'light':
848
- # ctx.add_basemap(ax, crs=crs_epsg_3857, source=ctx.providers.CartoDB.Positron)
849
- # elif basemap == 'voyager':
850
- # ctx.add_basemap(ax, crs=crs_epsg_3857, source=ctx.providers.CartoDB.Voyager)
851
-
852
- if data_type == 'building_height':
853
- buildings = kwargs.get('buildings', [])
854
- for building in buildings:
855
- polygon = Polygon(building['geometry']['coordinates'][0])
856
- x, y = polygon.exterior.xy
857
- x, y = zip(*[transformer.transform(lon, lat) for lat, lon in zip(x, y)])
858
- ax.plot(x, y, color='red', linewidth=1.5)
859
- # print(polygon)
860
-
861
- # Safe calculation of plot limits
862
- all_coords = np.array(vertices)
863
- x, y = zip(*[transformer.transform(lon, lat) for lat, lon in all_coords])
864
-
865
- # Calculate limits safely
866
- x_min, x_max = min(x), max(x)
867
- y_min, y_max = min(y), max(y)
868
-
869
- if x_min != x_max and y_min != y_max and buf != 0:
870
- dist_x = x_max - x_min
871
- dist_y = y_max - y_min
872
- # Set limits with buffer
873
- ax.set_xlim(x_min - buf * dist_x, x_max + buf * dist_x)
874
- ax.set_ylim(y_min - buf * dist_y, y_max + buf * dist_y)
875
- else:
876
- # If coordinates are the same or buffer is 0, set limits without buffer
877
- ax.set_xlim(x_min, x_max)
878
- ax.set_ylim(y_min, y_max)
879
-
880
- plt.axis('off')
881
- plt.tight_layout()
882
- plt.show()
883
-
884
- def display_builing_ids_on_map(building_geojson, rectangle_vertices):
885
- """
886
- Creates an interactive folium map displaying building footprints with selectable IDs.
887
-
888
- This function generates a web map showing building polygons within a circular area
889
- around the center of the specified rectangle. Each building is labeled with its
890
- ID and additional information, making it easy to identify specific buildings
891
- for analysis or selection.
892
-
893
- Parameters:
894
- -----------
895
- building_geojson : list
896
- List of GeoJSON feature dictionaries representing building polygons.
897
- Each feature should have:
898
- - 'geometry': GeoJSON polygon geometry
899
- - 'properties': Dictionary with 'id' and optional 'name' fields
900
-
901
- rectangle_vertices : list
902
- List of [lat, lon] coordinate pairs defining the area of interest.
903
- Used to calculate the map center and intersection area.
904
-
905
- Returns:
906
- --------
907
- folium.Map
908
- Interactive folium map object with building polygons and labels.
909
-
910
- Notes:
911
- ------
912
- - Only buildings intersecting with a 200m radius circle are displayed
913
- - Building IDs are displayed as selectable text labels
914
- - Map is automatically centered on the rectangle vertices
915
- - Popup information includes building ID and name (if available)
916
- - Building polygons are styled with blue color and semi-transparent fill
917
-
918
- Examples:
919
- ---------
920
- >>> vertices = [[40.7580, -73.9855], [40.7590, -73.9855],
921
- ... [40.7590, -73.9845], [40.7580, -73.9845]]
922
- >>> buildings = [{'geometry': {...}, 'properties': {'id': '123', 'name': 'Building A'}}]
923
- >>> map_obj = display_builing_ids_on_map(buildings, vertices)
924
- >>> map_obj.save('buildings_map.html')
925
- """
926
- # Parse the GeoJSON data
927
- geojson_data = building_geojson
928
-
929
- # Calculate the center point of the rectangle for map centering
930
- # Extract all latitudes and longitudes
931
- lats = [coord[0] for coord in rectangle_vertices]
932
- lons = [coord[1] for coord in rectangle_vertices]
933
-
934
- # Calculate center by averaging min and max values
935
- center_lat = (min(lats) + max(lats)) / 2
936
- center_lon = (min(lons) + max(lons)) / 2
937
-
938
- # Create circle polygon for intersection testing (200m radius)
939
- circle = create_circle_polygon(center_lat, center_lon, 200)
940
-
941
- # Create a map centered on the data
942
- m = folium.Map(location=[center_lat, center_lon], zoom_start=17)
943
-
944
- # Process each building feature
945
- # Add building footprints to the map
946
- for feature in geojson_data:
947
- # Convert coordinates if needed
948
- coords = convert_coordinates(feature['geometry']['coordinates'][0])
949
- building_polygon = Polygon(coords)
950
-
951
- # Only process buildings that intersect with the circular area
952
- # Check if building intersects with circle
953
- if building_polygon.intersects(circle):
954
- # Extract building information from properties
955
- # Get and format building properties
956
- # building_id = format_building_id(feature['properties'].get('id', 0))
957
- building_id = str(feature['properties'].get('id', 0))
958
- building_name = feature['properties'].get('name:en',
959
- feature['properties'].get('name', f'Building {building_id}'))
960
-
961
- # Create popup content with selectable ID
962
- popup_content = f"""
963
- <div>
964
- Building ID: <span style="user-select: all">{building_id}</span><br>
965
- Name: {building_name}
966
- </div>
967
- """
968
-
969
- # Add building polygon to map with popup information
970
- # Add polygon to map
971
- folium.Polygon(
972
- locations=coords,
973
- popup=folium.Popup(popup_content),
974
- color='blue',
975
- weight=2,
976
- fill=True,
977
- fill_color='blue',
978
- fill_opacity=0.2
979
- ).add_to(m)
980
-
981
- # Add building ID label at the polygon centroid
982
- # Calculate centroid for label placement
983
- centroid = calculate_centroid(coords)
984
-
985
- # Add building ID as a selectable label
986
- folium.Marker(
987
- centroid,
988
- icon=folium.DivIcon(
989
- html=f'''
990
- <div style="
991
- position: relative;
992
- font-family: monospace;
993
- font-size: 12px;
994
- color: black;
995
- background-color: rgba(255, 255, 255, 0.9);
996
- padding: 5px 8px;
997
- margin: -10px -15px;
998
- border: 1px solid black;
999
- border-radius: 4px;
1000
- user-select: all;
1001
- cursor: text;
1002
- white-space: nowrap;
1003
- display: inline-block;
1004
- box-shadow: 0 0 3px rgba(0,0,0,0.2);
1005
- ">{building_id}</div>
1006
- ''',
1007
- class_name="building-label"
1008
- )
1009
- ).add_to(m)
1010
-
1011
- # Save the map
1012
- return m
1013
-
1014
- def visualize_land_cover_grid_on_map(grid, rectangle_vertices, meshsize, source = 'Urbanwatch', vmin=None, vmax=None, alpha=0.5, buf=0.2, edge=True, basemap='CartoDB light'):
1015
- """
1016
- Visualizes land cover classification grid overlaid on a basemap.
1017
-
1018
- This function creates a map visualization of land cover data using predefined
1019
- color schemes for different land cover classes. Each grid cell is colored
1020
- according to its dominant land cover type and overlaid on a web basemap
1021
- for geographic context.
1022
-
1023
- Parameters:
1024
- -----------
1025
- grid : numpy.ndarray
1026
- 2D array containing land cover class indices. Values should correspond
1027
- to indices in the land cover classification system.
1028
-
1029
- rectangle_vertices : list
1030
- List of [lon, lat] coordinate pairs defining the grid boundary corners.
1031
- Should contain exactly 4 vertices in geographic coordinates.
1032
-
1033
- meshsize : float
1034
- Target size of each grid cell in meters. May be adjusted during processing.
1035
-
1036
- source : str, optional
1037
- Source of land cover classification system. Default is 'Urbanwatch'.
1038
- See get_land_cover_classes() for available options.
1039
-
1040
- vmin, vmax : float, optional
1041
- Not used for land cover (discrete categories). Included for API consistency.
1042
-
1043
- alpha : float, optional
1044
- Transparency of grid overlay (0-1). Default is 0.5.
1045
-
1046
- buf : float, optional
1047
- Buffer around grid for plot extent as fraction of grid size. Default is 0.2.
1048
-
1049
- edge : bool, optional
1050
- Whether to draw cell edges. Default is True.
1051
-
1052
- basemap : str, optional
1053
- Basemap style name. Options include:
1054
- - 'CartoDB light' (default)
1055
- - 'CartoDB dark'
1056
- - 'CartoDB voyager'
1057
- Default is 'CartoDB light'.
1058
-
1059
- Returns:
1060
- --------
1061
- None
1062
- Displays the plot and prints information about unique land cover classes.
1063
-
1064
- Notes:
1065
- ------
1066
- - Grid coordinates are calculated using geodetic calculations
1067
- - Land cover classes are mapped to predefined colors
1068
- - Unique classes present in the grid are printed for reference
1069
- - Uses Web Mercator projection (EPSG:3857) for basemap compatibility
1070
-
1071
- Examples:
1072
- ---------
1073
- >>> # Basic land cover visualization
1074
- >>> vertices = [[lon1, lat1], [lon2, lat2], [lon3, lat3], [lon4, lat4]]
1075
- >>> visualize_land_cover_grid_on_map(lc_grid, vertices, 10.0)
1076
-
1077
- >>> # With custom styling
1078
- >>> visualize_land_cover_grid_on_map(lc_grid, vertices, 10.0,
1079
- ... alpha=0.7, edge=False,
1080
- ... basemap='CartoDB dark')
1081
- """
1082
- # Initialize geodetic calculator for distance measurements
1083
- geod = initialize_geod()
1084
-
1085
- # Get land cover class definitions and colors
1086
- land_cover_classes = get_land_cover_classes(source)
1087
-
1088
- # Extract key vertices for grid calculations
1089
- vertex_0 = rectangle_vertices[0]
1090
- vertex_1 = rectangle_vertices[1]
1091
- vertex_3 = rectangle_vertices[3]
1092
-
1093
- # Calculate distances between vertices using geodetic calculations
1094
- dist_side_1 = calculate_distance(geod, vertex_0[1], vertex_0[0], vertex_1[1], vertex_1[0])
1095
- dist_side_2 = calculate_distance(geod, vertex_0[1], vertex_0[0], vertex_3[1], vertex_3[0])
1096
-
1097
- # Calculate side vectors in geographic coordinates
1098
- side_1 = np.array(vertex_1) - np.array(vertex_0)
1099
- side_2 = np.array(vertex_3) - np.array(vertex_0)
1100
-
1101
- # Create normalized unit vectors for grid orientation
1102
- u_vec = normalize_to_one_meter(side_1, dist_side_1)
1103
- v_vec = normalize_to_one_meter(side_2, dist_side_2)
1104
-
1105
- # Set grid origin and calculate optimal grid size
1106
- origin = np.array(rectangle_vertices[0])
1107
- grid_size, adjusted_meshsize = calculate_grid_size(side_1, side_2, u_vec, v_vec, meshsize)
1108
-
1109
- print(f"Calculated grid size: {grid_size}")
1110
- # print(f"Adjusted mesh size: {adjusted_meshsize}")
1111
-
1112
- # Set up coordinate transformation for basemap compatibility
1113
- geotiff_crs = CRS.from_epsg(3857)
1114
- transformer = setup_transformer(CRS.from_epsg(4326), geotiff_crs)
1115
-
1116
- # Generate grid cell coordinates (not currently used but available for advanced processing)
1117
- cell_coords = create_coordinate_mesh(origin, grid_size, adjusted_meshsize, u_vec, v_vec)
1118
- cell_coords_flat = cell_coords.reshape(2, -1).T
1119
- transformed_coords = np.array([transform_coords(transformer, lon, lat) for lat, lon in cell_coords_flat])
1120
- transformed_coords = transformed_coords.reshape(grid_size[::-1] + (2,))
1121
-
1122
- # print(f"Grid shape: {grid.shape}")
1123
-
1124
- # Create the visualization using the general plot_grid function
1125
- plot_grid(grid, origin, adjusted_meshsize, u_vec, v_vec, transformer,
1126
- rectangle_vertices, 'land_cover', alpha=alpha, buf=buf, edge=edge, basemap=basemap, land_cover_classes=land_cover_classes)
1127
-
1128
- # Display information about the land cover classes present in the grid
1129
- unique_indices = np.unique(grid)
1130
- unique_classes = [list(land_cover_classes.values())[i] for i in unique_indices]
1131
- # print(f"Unique classes in the grid: {unique_classes}")
1132
-
1133
- def visualize_building_height_grid_on_map(building_height_grid, filtered_buildings, rectangle_vertices, meshsize, vmin=None, vmax=None, color_map=None, alpha=0.5, buf=0.2, edge=True, basemap='CartoDB light'):
1134
- # Calculate grid and normalize vectors
1135
- geod = initialize_geod()
1136
- vertex_0, vertex_1, vertex_3 = rectangle_vertices[0], rectangle_vertices[1], rectangle_vertices[3]
1137
-
1138
- dist_side_1 = calculate_distance(geod, vertex_0[1], vertex_0[0], vertex_1[1], vertex_1[0])
1139
- dist_side_2 = calculate_distance(geod, vertex_0[1], vertex_0[0], vertex_3[1], vertex_3[0])
1140
-
1141
- side_1 = np.array(vertex_1) - np.array(vertex_0)
1142
- side_2 = np.array(vertex_3) - np.array(vertex_0)
1143
-
1144
- u_vec = normalize_to_one_meter(side_1, dist_side_1)
1145
- v_vec = normalize_to_one_meter(side_2, dist_side_2)
1146
-
1147
- origin = np.array(rectangle_vertices[0])
1148
- _, adjusted_meshsize = calculate_grid_size(side_1, side_2, u_vec, v_vec, meshsize)
1149
-
1150
- # Setup transformer and plotting extent
1151
- transformer = setup_transformer(CRS.from_epsg(4326), CRS.from_epsg(3857))
1152
-
1153
- # Plot the results
1154
- plot_grid(building_height_grid, origin, adjusted_meshsize, u_vec, v_vec, transformer,
1155
- rectangle_vertices, 'building_height', vmin=vmin, vmax=vmax, color_map=color_map, alpha=alpha, buf=buf, edge=edge, basemap=basemap, buildings=filtered_buildings)
1156
-
1157
- def visualize_numerical_grid_on_map(canopy_height_grid, rectangle_vertices, meshsize, type, vmin=None, vmax=None, color_map=None, alpha=0.5, buf=0.2, edge=True, basemap='CartoDB light'):
1158
- # Calculate grid and normalize vectors
1159
- geod = initialize_geod()
1160
- vertex_0, vertex_1, vertex_3 = rectangle_vertices[0], rectangle_vertices[1], rectangle_vertices[3]
1161
-
1162
- dist_side_1 = calculate_distance(geod, vertex_0[1], vertex_0[0], vertex_1[1], vertex_1[0])
1163
- dist_side_2 = calculate_distance(geod, vertex_0[1], vertex_0[0], vertex_3[1], vertex_3[0])
1164
-
1165
- side_1 = np.array(vertex_1) - np.array(vertex_0)
1166
- side_2 = np.array(vertex_3) - np.array(vertex_0)
1167
-
1168
- u_vec = normalize_to_one_meter(side_1, dist_side_1)
1169
- v_vec = normalize_to_one_meter(side_2, dist_side_2)
1170
-
1171
- origin = np.array(rectangle_vertices[0])
1172
- _, adjusted_meshsize = calculate_grid_size(side_1, side_2, u_vec, v_vec, meshsize)
1173
-
1174
- # Setup transformer and plotting extent
1175
- transformer = setup_transformer(CRS.from_epsg(4326), CRS.from_epsg(3857))
1176
-
1177
- # Plot the results
1178
- plot_grid(canopy_height_grid, origin, adjusted_meshsize, u_vec, v_vec, transformer,
1179
- rectangle_vertices, type, vmin=vmin, vmax=vmax, color_map=color_map, alpha=alpha, buf=buf, edge=edge, basemap=basemap)
1180
-
1181
- def visualize_land_cover_grid(grid, mesh_size, color_map, land_cover_classes):
1182
- all_classes = list(land_cover_classes.values())
1183
- unique_classes = list(dict.fromkeys(all_classes)) # Preserve order and remove duplicates
1184
-
1185
- colors = [color_map[cls] for cls in unique_classes]
1186
- cmap = mcolors.ListedColormap(colors)
1187
-
1188
- bounds = np.arange(len(unique_classes) + 1)
1189
- norm = mcolors.BoundaryNorm(bounds, cmap.N)
1190
-
1191
- class_to_num = {cls: i for i, cls in enumerate(unique_classes)}
1192
- numeric_grid = np.vectorize(class_to_num.get)(grid)
1193
-
1194
- plt.figure(figsize=(10, 10))
1195
- im = plt.imshow(numeric_grid, cmap=cmap, norm=norm, interpolation='nearest')
1196
- cbar = plt.colorbar(im, ticks=bounds[:-1] + 0.5)
1197
- cbar.set_ticklabels(unique_classes)
1198
- plt.title(f'Land Use/Land Cover Grid (Mesh Size: {mesh_size}m)')
1199
- plt.xlabel('Grid Cells (X)')
1200
- plt.ylabel('Grid Cells (Y)')
1201
- plt.show()
1202
-
1203
- def visualize_numerical_grid(grid, mesh_size, title, cmap='viridis', label='Value', vmin=None, vmax=None):
1204
- plt.figure(figsize=(10, 10))
1205
- plt.imshow(grid, cmap=cmap, vmin=vmin, vmax=vmax)
1206
- plt.colorbar(label=label)
1207
- plt.title(f'{title} (Mesh Size: {mesh_size}m)')
1208
- plt.xlabel('Grid Cells (X)')
1209
- plt.ylabel('Grid Cells (Y)')
1210
- plt.show()
1211
-
1212
- def convert_coordinates(coords):
1213
- return coords
1214
-
1215
- def calculate_centroid(coords):
1216
- lat_sum = sum(coord[0] for coord in coords)
1217
- lon_sum = sum(coord[1] for coord in coords)
1218
- return [lat_sum / len(coords), lon_sum / len(coords)]
1219
-
1220
- def calculate_center(features):
1221
- lats = []
1222
- lons = []
1223
- for feature in features:
1224
- coords = feature['geometry']['coordinates'][0]
1225
- for lat, lon in coords:
1226
- lats.append(lat)
1227
- lons.append(lon)
1228
- return sum(lats) / len(lats), sum(lons) / len(lons)
1229
-
1230
- def create_circle_polygon(center_lat, center_lon, radius_meters):
1231
- """Create a circular polygon with given center and radius"""
1232
- # Convert radius from meters to degrees (approximate)
1233
- radius_deg = radius_meters / 111000 # 1 degree ≈ 111km at equator
1234
-
1235
- # Create circle points
1236
- points = []
1237
- for angle in range(361): # 0 to 360 degrees
1238
- rad = math.radians(angle)
1239
- lat = center_lat + (radius_deg * math.cos(rad))
1240
- lon = center_lon + (radius_deg * math.sin(rad) / math.cos(math.radians(center_lat)))
1241
- points.append((lat, lon))
1242
- return Polygon(points)
1243
-
1244
- def visualize_landcover_grid_on_basemap(landcover_grid, rectangle_vertices, meshsize, source='Standard', alpha=0.6, figsize=(12, 8),
1245
- basemap='CartoDB light', show_edge=False, edge_color='black', edge_width=0.5):
1246
- """Visualizes a land cover grid GeoDataFrame using predefined color schemes.
1247
-
1248
- Args:
1249
- gdf: GeoDataFrame containing grid cells with 'geometry' and 'value' columns
1250
- source: Source of land cover classification (e.g., 'Standard', 'Urbanwatch', etc.)
1251
- title: Title for the plot (default: None)
1252
- alpha: Transparency of the grid overlay (default: 0.6)
1253
- figsize: Figure size in inches (default: (12, 8))
1254
- basemap: Basemap style (default: 'CartoDB light')
1255
- show_edge: Whether to show cell edges (default: True)
1256
- edge_color: Color of cell edges (default: 'black')
1257
- edge_width: Width of cell edges (default: 0.5)
1258
- """
1259
- # Get land cover classes and colors
1260
- land_cover_classes = get_land_cover_classes(source)
1261
-
1262
- gdf = grid_to_geodataframe(landcover_grid, rectangle_vertices, meshsize)
1263
-
1264
- # Convert RGB tuples to normalized RGB values
1265
- colors = [(r/255, g/255, b/255) for (r,g,b) in land_cover_classes.keys()]
1266
-
1267
- # Create custom colormap
1268
- cmap = ListedColormap(colors)
1269
-
1270
- # Create bounds for discrete colorbar
1271
- bounds = np.arange(len(colors) + 1)
1272
- norm = BoundaryNorm(bounds, cmap.N)
1273
-
1274
- # Convert to Web Mercator
1275
- gdf_web = gdf.to_crs(epsg=3857)
1276
-
1277
- # Create figure and axis
1278
- fig, ax = plt.subplots(figsize=figsize)
1279
-
1280
- # Plot the GeoDataFrame
1281
- gdf_web.plot(column='value',
1282
- ax=ax,
1283
- alpha=alpha,
1284
- cmap=cmap,
1285
- norm=norm,
1286
- legend=True,
1287
- legend_kwds={
1288
- 'label': 'Land Cover Class',
1289
- 'ticks': bounds[:-1] + 0.5,
1290
- 'boundaries': bounds,
1291
- 'format': lambda x, p: list(land_cover_classes.values())[int(x)]
1292
- },
1293
- edgecolor=edge_color if show_edge else 'none',
1294
- linewidth=edge_width if show_edge else 0)
1295
-
1296
- # Add basemap
1297
- basemaps = {
1298
- 'CartoDB dark': ctx.providers.CartoDB.DarkMatter,
1299
- 'CartoDB light': ctx.providers.CartoDB.Positron,
1300
- 'CartoDB voyager': ctx.providers.CartoDB.Voyager,
1301
- 'CartoDB light no labels': ctx.providers.CartoDB.PositronNoLabels,
1302
- 'CartoDB dark no labels': ctx.providers.CartoDB.DarkMatterNoLabels,
1303
- }
1304
- ctx.add_basemap(ax, source=basemaps[basemap])
1305
-
1306
- # Set title and remove axes
1307
- ax.set_axis_off()
1308
-
1309
- plt.tight_layout()
1310
- plt.show()
1311
-
1312
- def visualize_numerical_grid_on_basemap(grid, rectangle_vertices, meshsize, value_name="value", cmap='viridis', vmin=None, vmax=None,
1313
- alpha=0.6, figsize=(12, 8), basemap='CartoDB light',
1314
- show_edge=False, edge_color='black', edge_width=0.5):
1315
- """Visualizes a numerical grid GeoDataFrame (e.g., heights) on a basemap.
1316
-
1317
- Args:
1318
- gdf: GeoDataFrame containing grid cells with 'geometry' and 'value' columns
1319
- title: Title for the plot (default: None)
1320
- cmap: Colormap to use (default: 'viridis')
1321
- vmin: Minimum value for colormap scaling (default: None)
1322
- vmax: Maximum value for colormap scaling (default: None)
1323
- alpha: Transparency of the grid overlay (default: 0.6)
1324
- figsize: Figure size in inches (default: (12, 8))
1325
- basemap: Basemap style (default: 'CartoDB light')
1326
- show_edge: Whether to show cell edges (default: True)
1327
- edge_color: Color of cell edges (default: 'black')
1328
- edge_width: Width of cell edges (default: 0.5)
1329
- """
1330
-
1331
- gdf = grid_to_geodataframe(grid, rectangle_vertices, meshsize)
1332
-
1333
- # Convert to Web Mercator
1334
- gdf_web = gdf.to_crs(epsg=3857)
1335
-
1336
- # Create figure and axis
1337
- fig, ax = plt.subplots(figsize=figsize)
1338
-
1339
- # Plot the GeoDataFrame
1340
- gdf_web.plot(column='value',
1341
- ax=ax,
1342
- alpha=alpha,
1343
- cmap=cmap,
1344
- vmin=vmin,
1345
- vmax=vmax,
1346
- legend=True,
1347
- legend_kwds={'label': value_name},
1348
- edgecolor=edge_color if show_edge else 'none',
1349
- linewidth=edge_width if show_edge else 0)
1350
-
1351
- # Add basemap
1352
- basemaps = {
1353
- 'CartoDB dark': ctx.providers.CartoDB.DarkMatter,
1354
- 'CartoDB light': ctx.providers.CartoDB.Positron,
1355
- 'CartoDB voyager': ctx.providers.CartoDB.Voyager,
1356
- 'CartoDB light no labels': ctx.providers.CartoDB.PositronNoLabels,
1357
- 'CartoDB dark no labels': ctx.providers.CartoDB.DarkMatterNoLabels,
1358
- }
1359
- ctx.add_basemap(ax, source=basemaps[basemap])
1360
-
1361
- # Set title and remove axes
1362
- ax.set_axis_off()
1363
-
1364
- plt.tight_layout()
1365
- plt.show()
1366
-
1367
- def visualize_numerical_gdf_on_basemap(gdf, value_name="value", cmap='viridis', vmin=None, vmax=None,
1368
- alpha=0.6, figsize=(12, 8), basemap='CartoDB light',
1369
- show_edge=False, edge_color='black', edge_width=0.5,
1370
- input_crs=None):
1371
- """Visualizes a GeoDataFrame with numerical values on a basemap.
1372
-
1373
- Args:
1374
- gdf: GeoDataFrame containing grid cells with 'geometry' and 'value' columns
1375
- value_name: Name of the value column and legend label (default: "value")
1376
- cmap: Colormap to use (default: 'viridis')
1377
- vmin: Minimum value for colormap scaling (default: None)
1378
- vmax: Maximum value for colormap scaling (default: None)
1379
- alpha: Transparency of the grid overlay (default: 0.6)
1380
- figsize: Figure size in inches (default: (12, 8))
1381
- basemap: Basemap style (default: 'CartoDB light')
1382
- show_edge: Whether to show cell edges (default: False)
1383
- edge_color: Color of cell edges (default: 'black')
1384
- edge_width: Width of cell edges (default: 0.5)
1385
- input_crs: Optional CRS to assign if the GeoDataFrame has no CRS. If not provided
1386
- and CRS is missing, the function will attempt to infer WGS84 (EPSG:4326)
1387
- when coordinates look like lon/lat; otherwise it will raise a clear error.
1388
- """
1389
- # Ensure CRS is defined; if missing, assign or infer
1390
- if gdf.crs is None:
1391
- if input_crs is not None:
1392
- gdf = gdf.set_crs(input_crs, allow_override=True)
1393
- else:
1394
- # Try to infer WGS84 if bounds look like lon/lat
1395
- try:
1396
- minx, miny, maxx, maxy = gdf.total_bounds
1397
- looks_like_lonlat = (
1398
- -180.0 <= minx <= 180.0 and -180.0 <= maxx <= 180.0 and
1399
- -90.0 <= miny <= 90.0 and -90.0 <= maxy <= 90.0
1400
- )
1401
- except Exception:
1402
- looks_like_lonlat = False
1403
- if looks_like_lonlat:
1404
- gdf = gdf.set_crs("EPSG:4326", allow_override=True)
1405
- else:
1406
- raise ValueError(
1407
- "Input GeoDataFrame has no CRS. Provide 'input_crs' (e.g., 'EPSG:4326' or 'EPSG:XXXX') "
1408
- "or set gdf.crs before calling visualize_numerical_gdf_on_basemap."
1409
- )
1410
-
1411
- # Convert to Web Mercator if not already in that CRS
1412
- if str(gdf.crs) != 'EPSG:3857':
1413
- gdf_web = gdf.to_crs(epsg=3857)
1414
- else:
1415
- gdf_web = gdf
1416
-
1417
- # Create figure and axis
1418
- fig, ax = plt.subplots(figsize=figsize)
1419
-
1420
- # Plot the GeoDataFrame
1421
- gdf_web.plot(column=value_name,
1422
- ax=ax,
1423
- alpha=alpha,
1424
- cmap=cmap,
1425
- vmin=vmin,
1426
- vmax=vmax,
1427
- legend=True,
1428
- legend_kwds={'label': value_name},
1429
- edgecolor=edge_color if show_edge else 'none',
1430
- linewidth=edge_width if show_edge else 0)
1431
-
1432
- # Add basemap
1433
- basemaps = {
1434
- 'CartoDB dark': ctx.providers.CartoDB.DarkMatter,
1435
- 'CartoDB light': ctx.providers.CartoDB.Positron,
1436
- 'CartoDB voyager': ctx.providers.CartoDB.Voyager,
1437
- 'CartoDB light no labels': ctx.providers.CartoDB.PositronNoLabels,
1438
- 'CartoDB dark no labels': ctx.providers.CartoDB.DarkMatterNoLabels,
1439
- }
1440
- ctx.add_basemap(ax, source=basemaps[basemap])
1441
-
1442
- # Set title and remove axes
1443
- ax.set_axis_off()
1444
-
1445
- plt.tight_layout()
1446
- plt.show()
1447
-
1448
- def visualize_point_gdf_on_basemap(point_gdf, value_name='value', **kwargs):
1449
- """Visualizes a point GeoDataFrame on a basemap with colors based on values.
1450
-
1451
- Args:
1452
- point_gdf: GeoDataFrame with point geometries and values
1453
- value_name: Name of the column containing values to visualize (default: 'value')
1454
- **kwargs: Optional visualization parameters including:
1455
- - figsize: Tuple for figure size (default: (12, 8))
1456
- - colormap: Matplotlib colormap name (default: 'viridis')
1457
- - markersize: Size of points (default: 20)
1458
- - alpha: Transparency of points (default: 0.7)
1459
- - vmin: Minimum value for colormap scaling (default: None)
1460
- - vmax: Maximum value for colormap scaling (default: None)
1461
- - title: Plot title (default: None)
1462
- - basemap_style: Contextily basemap style (default: CartoDB.Positron)
1463
- - zoom: Basemap zoom level (default: 15)
1464
-
1465
- Returns:
1466
- matplotlib figure and axis objects
1467
- """
1468
- import matplotlib.pyplot as plt
1469
- import contextily as ctx
1470
-
1471
- # Set default parameters
1472
- defaults = {
1473
- 'figsize': (12, 8),
1474
- 'colormap': 'viridis',
1475
- 'markersize': 20,
1476
- 'alpha': 0.7,
1477
- 'vmin': None,
1478
- 'vmax': None,
1479
- 'title': None,
1480
- 'basemap_style': ctx.providers.CartoDB.Positron,
1481
- 'zoom': 15
1482
- }
1483
-
1484
- # Update defaults with provided kwargs
1485
- settings = {**defaults, **kwargs}
1486
-
1487
- # Create figure and axis
1488
- fig, ax = plt.subplots(figsize=settings['figsize'])
1489
-
1490
- # Convert to Web Mercator for basemap compatibility
1491
- point_gdf_web = point_gdf.to_crs(epsg=3857)
1492
-
1493
- # Plot points
1494
- scatter = point_gdf_web.plot(
1495
- column=value_name,
1496
- ax=ax,
1497
- cmap=settings['colormap'],
1498
- markersize=settings['markersize'],
1499
- alpha=settings['alpha'],
1500
- vmin=settings['vmin'],
1501
- vmax=settings['vmax'],
1502
- legend=True,
1503
- legend_kwds={
1504
- 'label': value_name,
1505
- 'orientation': 'vertical',
1506
- 'shrink': 0.8
1507
- }
1508
- )
1509
-
1510
- # Add basemap
1511
- ctx.add_basemap(
1512
- ax,
1513
- source=settings['basemap_style'],
1514
- zoom=settings['zoom']
1515
- )
1516
-
1517
- # Set title if provided
1518
- if settings['title']:
1519
- plt.title(settings['title'])
1520
-
1521
- # Remove axes
1522
- ax.set_axis_off()
1523
-
1524
- # Adjust layout to prevent colorbar cutoff
1525
- plt.tight_layout()
1526
- plt.show()
1527
-
1528
- def create_multi_view_scene(meshes, output_directory="output", projection_type="perspective", distance_factor=1.0):
1529
- """
1530
- Creates multiple rendered views of 3D city meshes from different camera angles.
1531
-
1532
- This function generates a comprehensive set of views including isometric and
1533
- orthographic projections of the 3D city model. Each view is rendered as a
1534
- high-quality image and saved to the specified directory.
1535
-
1536
- Parameters:
1537
- -----------
1538
- meshes : dict
1539
- Dictionary mapping mesh names/IDs to trimesh.Trimesh objects.
1540
- Each mesh represents a different component of the city model.
1541
-
1542
- output_directory : str, optional
1543
- Directory path where rendered images will be saved. Default is "output".
1544
-
1545
- projection_type : str, optional
1546
- Camera projection type. Options:
1547
- - "perspective": Natural perspective projection (default)
1548
- - "orthographic": Technical orthographic projection
1549
-
1550
- distance_factor : float, optional
1551
- Multiplier for camera distance from the scene. Default is 1.0.
1552
- Higher values move camera further away, lower values bring it closer.
1553
-
1554
- Returns:
1555
- --------
1556
- list of tuple
1557
- List of (view_name, filename) pairs for each generated image.
1558
-
1559
- Notes:
1560
- ------
1561
- - Generates 9 different views: 4 isometric + 5 orthographic
1562
- - Isometric views: front-right, front-left, back-right, back-left
1563
- - Orthographic views: top, front, back, left, right
1564
- - Uses PyVista for high-quality rendering with proper lighting
1565
- - Camera positions are automatically calculated based on scene bounds
1566
- - Images are saved as PNG files with high DPI
1567
-
1568
- Technical Details:
1569
- ------------------
1570
- - Scene bounding box is computed from all mesh vertices
1571
- - Camera distance is scaled based on scene diagonal
1572
- - Orthographic projection uses parallel scaling for technical drawings
1573
- - Each view uses optimized lighting for clarity
1574
-
1575
- Examples:
1576
- ---------
1577
- >>> meshes = {'buildings': building_mesh, 'ground': ground_mesh}
1578
- >>> views = create_multi_view_scene(meshes, "renders/", "orthographic", 1.5)
1579
- >>> print(f"Generated {len(views)} views")
1580
- """
1581
- # Precompute PyVista meshes once to avoid repeated conversion per view
1582
- pv_meshes = {}
1583
- for class_id, mesh in meshes.items():
1584
- if mesh is None or len(mesh.vertices) == 0 or len(mesh.faces) == 0:
1585
- continue
1586
- # PyVista expects a faces array where each face is prefixed by its vertex count (3 for triangles)
1587
- faces = np.hstack([[3, *face] for face in mesh.faces])
1588
- pv_mesh = pv.PolyData(mesh.vertices, faces)
1589
- # Attach per-cell colors if provided
1590
- colors = getattr(mesh.visual, 'face_colors', None)
1591
- if colors is not None:
1592
- colors = np.asarray(colors)
1593
- if colors.size and colors.max() > 1:
1594
- colors = colors / 255.0
1595
- pv_mesh.cell_data['colors'] = colors
1596
- pv_meshes[class_id] = pv_mesh
1597
-
1598
- # Compute overall bounding box across all meshes without stacking
1599
- min_xyz = np.array([np.inf, np.inf, np.inf], dtype=float)
1600
- max_xyz = np.array([-np.inf, -np.inf, -np.inf], dtype=float)
1601
- for mesh in meshes.values():
1602
- if mesh is None or len(mesh.vertices) == 0:
1603
- continue
1604
- v = mesh.vertices
1605
- # update mins and maxs
1606
- min_xyz = np.minimum(min_xyz, v.min(axis=0))
1607
- max_xyz = np.maximum(max_xyz, v.max(axis=0))
1608
- bbox = np.vstack([min_xyz, max_xyz])
1609
-
1610
- # Compute the center and diagonal of the bounding box
1611
- center = (bbox[1] + bbox[0]) / 2
1612
- diagonal = np.linalg.norm(bbox[1] - bbox[0])
1613
-
1614
- # Adjust distance based on projection type
1615
- if projection_type.lower() == "orthographic":
1616
- distance = diagonal * 5 # Increase distance for orthographic to capture full scene
1617
- else:
1618
- distance = diagonal * 1.8 * distance_factor # Original distance for perspective
1619
-
1620
- # Define the isometric viewing angles
1621
- iso_angles = {
1622
- 'iso_front_right': (1, 1, 0.7),
1623
- 'iso_front_left': (-1, 1, 0.7),
1624
- 'iso_back_right': (1, -1, 0.7),
1625
- 'iso_back_left': (-1, -1, 0.7)
1626
- }
1627
-
1628
- # Compute camera positions for isometric views
1629
- camera_positions = {}
1630
- for name, direction in iso_angles.items():
1631
- direction = np.array(direction)
1632
- direction = direction / np.linalg.norm(direction)
1633
- camera_pos = center + direction * distance
1634
- camera_positions[name] = [camera_pos, center, (0, 0, 1)]
1635
-
1636
- # Add orthographic views
1637
- ortho_views = {
1638
- 'xy_top': [center + np.array([0, 0, distance]), center, (-1, 0, 0)],
1639
- 'yz_right': [center + np.array([distance, 0, 0]), center, (0, 0, 1)],
1640
- 'xz_front': [center + np.array([0, distance, 0]), center, (0, 0, 1)],
1641
- 'yz_left': [center + np.array([-distance, 0, 0]), center, (0, 0, 1)],
1642
- 'xz_back': [center + np.array([0, -distance, 0]), center, (0, 0, 1)]
1643
- }
1644
- camera_positions.update(ortho_views)
1645
-
1646
- images = []
1647
- for view_name, camera_pos in camera_positions.items():
1648
- # Create new plotter for each view (avoid notebook backend in headless/CI)
1649
- plotter = pv.Plotter(off_screen=True)
1650
-
1651
- # Set the projection type
1652
- if projection_type.lower() == "orthographic":
1653
- plotter.enable_parallel_projection()
1654
- # Set parallel scale to ensure the whole scene is visible
1655
- plotter.camera.parallel_scale = diagonal * 0.4 * distance_factor # Adjust this factor as needed
1656
-
1657
- elif projection_type.lower() != "perspective":
1658
- print(f"Warning: Unknown projection_type '{projection_type}'. Using perspective projection.")
1659
-
1660
- # Add each precomputed mesh to the scene
1661
- for class_id, pv_mesh in pv_meshes.items():
1662
- has_colors = 'colors' in pv_mesh.cell_data
1663
- plotter.add_mesh(
1664
- pv_mesh,
1665
- rgb=True,
1666
- scalars='colors' if has_colors else None
1667
- )
1668
-
1669
- # Set camera position for this view
1670
- plotter.camera_position = camera_pos
1671
-
1672
- # Save screenshot
1673
- filename = f'{output_directory}/city_view_{view_name}.png'
1674
- plotter.screenshot(filename)
1675
- images.append((view_name, filename))
1676
- plotter.close()
1677
-
1678
- return images
1679
-
1680
- def visualize_voxcity_multi_view(voxel_array, meshsize, **kwargs):
1681
- """
1682
- Creates comprehensive 3D visualizations of voxel city data with multiple viewing angles.
1683
-
1684
- This is the primary function for generating publication-quality renderings of voxel
1685
- city models. It converts voxel data to 3D meshes, optionally overlays simulation
1686
- results, and produces multiple rendered views from different camera positions.
1687
-
1688
- Parameters:
1689
- -----------
1690
- voxel_array : numpy.ndarray
1691
- 3D array containing voxel class IDs. Shape should be (x, y, z).
1692
-
1693
- meshsize : float
1694
- Physical size of each voxel in meters.
1695
-
1696
- **kwargs : dict
1697
- Optional visualization parameters:
1698
-
1699
- Color and Style:
1700
- - voxel_color_map (str): Color scheme name, default 'default'
1701
- - output_directory (str): Directory for output files, default 'output'
1702
- - output_file_name (str): Base name for exported files
1703
-
1704
- Simulation Overlay:
1705
- - sim_grid (numpy.ndarray): 2D simulation results to overlay
1706
- - dem_grid (numpy.ndarray): Digital elevation model for height reference
1707
- - view_point_height (float): Height offset for simulation surface, default 1.5m
1708
- - colormap (str): Matplotlib colormap for simulation data, default 'viridis'
1709
- - vmin, vmax (float): Color scale limits for simulation data
1710
-
1711
- Camera and Rendering:
1712
- - projection_type (str): 'perspective' or 'orthographic', default 'perspective'
1713
- - distance_factor (float): Camera distance multiplier, default 1.0
1714
- - window_width, window_height (int): Render resolution, default 1024x768
1715
-
1716
- Output Control:
1717
- - show_views (bool): Whether to display rendered views, default True
1718
- - save_obj (bool): Whether to export OBJ mesh files, default False
1719
-
1720
- Returns:
1721
- --------
1722
- None
1723
- Displays rendered views and optionally saves files to disk.
1724
-
1725
- Notes:
1726
- ------
1727
- - Automatically configures PyVista for headless rendering
1728
- - Generates meshes for each voxel class with appropriate colors
1729
- - Creates colorbar for simulation data if provided
1730
- - Produces 9 different camera views (4 isometric + 5 orthographic)
1731
- - Exports mesh files in OBJ format if requested
1732
-
1733
- Technical Requirements:
1734
- -----------------------
1735
- - Requires Xvfb for headless rendering on Linux systems
1736
- - Uses PyVista for high-quality 3D rendering
1737
- - Simulation data is interpolated onto elevated surface mesh
1738
-
1739
- Examples:
1740
- ---------
1741
- >>> # Basic visualization
1742
- >>> visualize_voxcity_multi_view(voxel_array, meshsize=2.0)
1743
-
1744
- >>> # With simulation results overlay
1745
- >>> visualize_voxcity_multi_view(
1746
- ... voxel_array, 2.0,
1747
- ... sim_grid=temperature_data,
1748
- ... dem_grid=elevation_data,
1749
- ... colormap='plasma',
1750
- ... output_file_name='temperature_analysis'
1751
- ... )
1752
-
1753
- >>> # High-resolution orthographic technical drawings
1754
- >>> visualize_voxcity_multi_view(
1755
- ... voxel_array, 2.0,
1756
- ... projection_type='orthographic',
1757
- ... window_width=2048,
1758
- ... window_height=1536,
1759
- ... save_obj=True
1760
- ... )
1761
- """
1762
- # Configure PyVista settings for high-quality rendering
1763
- pv.set_plot_theme('document')
1764
- pv.global_theme.background = 'white'
1765
- pv.global_theme.window_size = [1024, 768]
1766
- pv.global_theme.jupyter_backend = 'static'
1767
-
1768
- # Parse visualization parameters from kwargs
1769
- voxel_color_map = kwargs.get("voxel_color_map", 'default')
1770
- vox_dict = get_voxel_color_map(voxel_color_map)
1771
- output_directory = kwargs.get("output_directory", 'output')
1772
- base_filename = kwargs.get("output_file_name", None)
1773
- sim_grid = kwargs.get("sim_grid", None)
1774
- dem_grid_ori = kwargs.get("dem_grid", None)
1775
-
1776
- # Normalize DEM grid to start from zero elevation
1777
- if dem_grid_ori is not None:
1778
- dem_grid = dem_grid_ori - np.min(dem_grid_ori)
1779
-
1780
- # Simulation overlay parameters
1781
- z_offset = kwargs.get("view_point_height", 1.5)
1782
- cmap_name = kwargs.get("colormap", "viridis")
1783
- vmin = kwargs.get("vmin", np.nanmin(sim_grid) if sim_grid is not None else None)
1784
- vmax = kwargs.get("vmax", np.nanmax(sim_grid) if sim_grid is not None else None)
1785
-
1786
- # Camera and rendering parameters
1787
- projection_type = kwargs.get("projection_type", "perspective")
1788
- distance_factor = kwargs.get("distance_factor", 1.0)
1789
-
1790
- # Output control parameters
1791
- save_obj = kwargs.get("save_obj", False)
1792
- show_views = kwargs.get("show_views", True)
1793
- # Disable rendering in CI/headless to avoid VTK segfaults
1794
- if os.getenv('GITHUB_ACTIONS', 'false').lower() == 'true' or os.getenv('CI', 'false').lower() == 'true':
1795
- show_views = False
1796
-
1797
- # Set up headless rendering environment for PyVista only if we will render
1798
- if show_views and sys.platform.startswith('linux'):
1799
- try:
1800
- import pyvista as _pv
1801
- _pv.start_xvfb()
1802
- except Exception:
1803
- os.system('Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &')
1804
- os.environ['DISPLAY'] = ':99'
1805
-
1806
- # Create 3D meshes from voxel data
1807
- print("Creating voxel meshes...")
1808
- meshes = create_city_meshes(voxel_array, vox_dict, meshsize=meshsize)
1809
-
1810
- # Add simulation results as elevated surface mesh if provided
1811
- if sim_grid is not None and dem_grid is not None:
1812
- print("Creating sim_grid surface mesh...")
1813
- sim_mesh = create_sim_surface_mesh(
1814
- sim_grid, dem_grid,
1815
- meshsize=meshsize,
1816
- z_offset=z_offset,
1817
- cmap_name=cmap_name,
1818
- vmin=vmin,
1819
- vmax=vmax
1820
- )
1821
- if sim_mesh is not None:
1822
- meshes["sim_surface"] = sim_mesh
1823
-
1824
- # Create and display colorbar for simulation data
1825
- norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
1826
- scalar_map = cm.ScalarMappable(norm=norm, cmap=cmap_name)
1827
-
1828
- fig, ax = plt.subplots(figsize=(6, 1))
1829
- plt.colorbar(scalar_map, cax=ax, orientation='horizontal')
1830
- plt.tight_layout()
1831
- plt.show()
1832
-
1833
- # # Export mesh files if requested
1834
- # if base_filename is not None:
1835
- # print(f"Exporting files to '{base_filename}.*' ...")
1836
- # os.makedirs(output_directory, exist_ok=True)
1837
- # export_meshes(meshes, output_directory, base_filename)
1838
-
1839
- # Export OBJ mesh files if requested
1840
- if save_obj:
1841
- output_directory = kwargs.get('output_directory', 'output')
1842
- output_file_name = kwargs.get('output_file_name', 'voxcity_mesh')
1843
- obj_path, mtl_path = save_obj_from_colored_mesh(meshes, output_directory, output_file_name)
1844
- print(f"Saved mesh files to:\n {obj_path}\n {mtl_path}")
1845
-
1846
- # Generate and display multiple camera views
1847
- if show_views:
1848
- print("Creating multiple views...")
1849
- os.makedirs(output_directory, exist_ok=True)
1850
- image_files = create_multi_view_scene(meshes, output_directory=output_directory, projection_type=projection_type, distance_factor=distance_factor)
1851
-
1852
- # Display each rendered view
1853
- for view_name, img_file in image_files:
1854
- plt.figure(figsize=(24, 16))
1855
- img = plt.imread(img_file)
1856
- plt.imshow(img)
1857
- plt.title(view_name.replace('_', ' ').title(), pad=20)
1858
- plt.axis('off')
1859
- plt.show()
1860
- plt.close()
1861
-
1862
- def visualize_voxcity_multi_view_with_multiple_sim_grids(voxel_array, meshsize, sim_configs, **kwargs):
1863
- """
1864
- Create multiple views of the voxel city data with multiple simulation grids.
1865
-
1866
- Args:
1867
- voxel_array: 3D numpy array containing voxel data
1868
- meshsize: Size of each voxel/cell
1869
- sim_configs: List of dictionaries, each containing configuration for a simulation grid:
1870
- {
1871
- 'sim_grid': 2D numpy array of simulation values,
1872
- 'z_offset': height offset in meters (default: 1.5),
1873
- 'cmap_name': colormap name (default: 'viridis'),
1874
- 'vmin': minimum value for colormap (optional),
1875
- 'vmax': maximum value for colormap (optional),
1876
- 'label': label for the colorbar (optional)
1877
- }
1878
- **kwargs: Additional arguments including:
1879
- - vox_dict: Dictionary mapping voxel values to colors
1880
- - output_directory: Directory to save output images
1881
- - output_file_name: Base filename for exports
1882
- - dem_grid: DEM grid for height information
1883
- - projection_type: 'perspective' or 'orthographic'
1884
- - distance_factor: Factor to adjust camera distance
1885
- """
1886
- os.system('Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &')
1887
- os.environ['DISPLAY'] = ':99'
1888
-
1889
- # Configure PyVista settings
1890
- pv.set_plot_theme('document')
1891
- pv.global_theme.background = 'white'
1892
- window_width = kwargs.get("window_width", 1024)
1893
- window_height = kwargs.get("window_height", 768)
1894
- pv.global_theme.window_size = [window_width, window_height]
1895
- pv.global_theme.jupyter_backend = 'static'
1896
-
1897
- # Parse general kwargs
1898
- voxel_color_map = kwargs.get("voxel_color_map", 'default')
1899
- vox_dict = get_voxel_color_map(voxel_color_map)
1900
- output_directory = kwargs.get("output_directory", 'output')
1901
- base_filename = kwargs.get("output_file_name", None)
1902
- dem_grid_ori = kwargs.get("dem_grid", None)
1903
- projection_type = kwargs.get("projection_type", "perspective")
1904
- distance_factor = kwargs.get("distance_factor", 1.0)
1905
- show_views = kwargs.get("show_views", True)
1906
- save_obj = kwargs.get("save_obj", False)
1907
-
1908
- if dem_grid_ori is not None:
1909
- dem_grid = dem_grid_ori - np.min(dem_grid_ori)
1910
-
1911
- # Create meshes
1912
- print("Creating voxel meshes...")
1913
- meshes = create_city_meshes(voxel_array, vox_dict, meshsize=meshsize)
1914
-
1915
- # Process each simulation grid
1916
- for i, config in enumerate(sim_configs):
1917
- sim_grid = config['sim_grid']
1918
- if sim_grid is None or dem_grid is None:
1919
- continue
1920
-
1921
- z_offset = config.get('z_offset', 1.5)
1922
- cmap_name = config.get('cmap_name', 'viridis')
1923
- vmin = config.get('vmin', np.nanmin(sim_grid))
1924
- vmax = config.get('vmax', np.nanmax(sim_grid))
1925
- label = config.get('label', f'Simulation {i+1}')
1926
-
1927
- print(f"Creating sim_grid surface mesh for {label}...")
1928
- sim_mesh = create_sim_surface_mesh(
1929
- sim_grid, dem_grid,
1930
- meshsize=meshsize,
1931
- z_offset=z_offset,
1932
- cmap_name=cmap_name,
1933
- vmin=vmin,
1934
- vmax=vmax
1935
- )
1936
-
1937
- if sim_mesh is not None:
1938
- meshes[f"sim_surface_{i}"] = sim_mesh
1939
-
1940
- # Create colorbar for this simulation
1941
- norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
1942
- scalar_map = cm.ScalarMappable(norm=norm, cmap=cmap_name)
1943
-
1944
- fig, ax = plt.subplots(figsize=(6, 1))
1945
- plt.colorbar(scalar_map, cax=ax, orientation='horizontal', label=label)
1946
- plt.tight_layout()
1947
- plt.show()
1948
-
1949
- # Export if filename provided
1950
- if base_filename is not None:
1951
- print(f"Exporting files to '{base_filename}.*' ...")
1952
- os.makedirs(output_directory, exist_ok=True)
1953
- export_meshes(meshes, output_directory, base_filename)
1954
-
1955
- if show_views:
1956
- # Create and save multiple views
1957
- print("Creating multiple views...")
1958
- os.makedirs(output_directory, exist_ok=True)
1959
- image_files = create_multi_view_scene(
1960
- meshes,
1961
- output_directory=output_directory,
1962
- projection_type=projection_type,
1963
- distance_factor=distance_factor
1964
- )
1965
-
1966
- # Display each view separately
1967
- for view_name, img_file in image_files:
1968
- plt.figure(figsize=(24, 16))
1969
- img = plt.imread(img_file)
1970
- plt.imshow(img)
1971
- plt.title(view_name.replace('_', ' ').title(), pad=20)
1972
- plt.axis('off')
1973
- plt.show()
1974
- plt.close()
1975
-
1976
- # After creating the meshes and before visualization
1977
- if save_obj:
1978
- output_directory = kwargs.get('output_directory', 'output')
1979
- output_file_name = kwargs.get('output_file_name', 'voxcity_mesh')
1980
- obj_path, mtl_path = save_obj_from_colored_mesh(meshes, output_directory, output_file_name)
1981
- print(f"Saved mesh files to:\n {obj_path}\n {mtl_path}")
1982
-
1983
- def visualize_voxcity_with_sim_meshes(voxel_array, meshsize, custom_meshes=None, **kwargs):
1984
- """
1985
- Creates 3D visualizations of voxel city data with custom simulation mesh overlays.
1986
-
1987
- This advanced visualization function allows replacement of specific voxel classes
1988
- with custom simulation result meshes. It's particularly useful for overlaying
1989
- detailed simulation results (like computational fluid dynamics, thermal analysis,
1990
- or environmental factors) onto specific building or infrastructure components.
1991
-
1992
- The function supports simulation meshes with metadata containing numerical values
1993
- that can be visualized using color mapping, making it ideal for displaying
1994
- spatially-varying simulation results on building surfaces or other urban elements.
1995
-
1996
- Parameters:
1997
- -----------
1998
- voxel_array : np.ndarray
1999
- 3D array of voxel values representing the base city model.
2000
- Shape should be (x, y, z) where each element is a voxel class ID.
2001
-
2002
- meshsize : float
2003
- Size of each voxel in meters, used for coordinate scaling.
2004
-
2005
- custom_meshes : dict, optional
2006
- Dictionary mapping voxel class IDs to custom trimesh.Trimesh objects.
2007
- Example: {-3: building_simulation_mesh, -2: vegetation_mesh}
2008
- These meshes will replace the standard voxel representation for visualization.
2009
- Default is None.
2010
-
2011
- **kwargs:
2012
- Extensive configuration options organized by category:
2013
-
2014
- Base Visualization:
2015
- - vox_dict (dict): Dictionary mapping voxel class IDs to colors
2016
- - output_directory (str): Directory for saving output files
2017
- - output_file_name (str): Base filename for exported meshes
2018
-
2019
- Simulation Result Display:
2020
- - value_name (str): Name of metadata field containing simulation values
2021
- - colormap (str): Matplotlib colormap name for simulation results
2022
- - vmin, vmax (float): Color scale limits for simulation data
2023
- - colorbar_title (str): Title for the simulation result colorbar
2024
- - nan_color (str/tuple): Color for NaN/invalid simulation values, default 'gray'
2025
-
2026
- Ground Surface Overlay:
2027
- - sim_grid (np.ndarray): 2D array with ground-level simulation values
2028
- - dem_grid (np.ndarray): Digital elevation model for terrain height
2029
- - view_point_height (float): Height offset for ground simulation surface
2030
-
2031
- Camera and Rendering:
2032
- - projection_type (str): 'perspective' or 'orthographic', default 'perspective'
2033
- - distance_factor (float): Camera distance multiplier, default 1.0
2034
- - window_width, window_height (int): Render resolution
2035
-
2036
- Output Control:
2037
- - show_views (bool): Whether to display rendered views, default True
2038
- - save_obj (bool): Whether to export OBJ mesh files, default False
2039
-
2040
- Returns:
2041
- --------
2042
- list
2043
- List of (view_name, image_file_path) tuples for generated views.
2044
- Only returned if show_views=True.
2045
-
2046
- Notes:
2047
- ------
2048
- Simulation Mesh Requirements:
2049
- - Custom meshes should have simulation values stored in mesh.metadata[value_name]
2050
- - Values can include NaN for areas without valid simulation data
2051
- - Mesh geometry should align with the voxel grid coordinate system
2052
-
2053
- Color Mapping:
2054
- - Simulation values are mapped to colors using the specified colormap
2055
- - NaN values are rendered in the specified nan_color
2056
- - A colorbar is automatically generated and displayed
2057
-
2058
- Technical Implementation:
2059
- - Uses PyVista for high-quality 3D rendering
2060
- - Supports both individual mesh coloring and ground surface overlays
2061
- - Automatically handles coordinate system transformations
2062
- - Generates multiple camera views for comprehensive visualization
2063
-
2064
- Examples:
2065
- ---------
2066
- >>> # Basic usage with building simulation results
2067
- >>> building_mesh = trimesh.load('building_with_cfd_results.ply')
2068
- >>> building_mesh.metadata = {'temperature': temperature_values}
2069
- >>> custom_meshes = {-3: building_mesh} # -3 is building class ID
2070
- >>>
2071
- >>> visualize_voxcity_with_sim_meshes(
2072
- ... voxel_array, meshsize=2.0,
2073
- ... custom_meshes=custom_meshes,
2074
- ... value_name='temperature',
2075
- ... colormap='plasma',
2076
- ... colorbar_title='Temperature (°C)',
2077
- ... vmin=15, vmax=35
2078
- ... )
2079
-
2080
- >>> # With ground-level wind simulation overlay
2081
- >>> wind_mesh = create_wind_simulation_mesh(wind_data)
2082
- >>> visualize_voxcity_with_sim_meshes(
2083
- ... voxel_array, 2.0,
2084
- ... custom_meshes={-3: wind_mesh},
2085
- ... value_name='wind_speed',
2086
- ... sim_grid=ground_wind_grid,
2087
- ... dem_grid=elevation_grid,
2088
- ... colormap='viridis',
2089
- ... projection_type='orthographic',
2090
- ... save_obj=True
2091
- ... )
2092
-
2093
- >>> # Multiple simulation types with custom styling
2094
- >>> meshes = {
2095
- ... -3: building_thermal_mesh, # Buildings with thermal data
2096
- ... -2: vegetation_co2_mesh # Vegetation with CO2 absorption
2097
- ... }
2098
- >>> visualize_voxcity_with_sim_meshes(
2099
- ... voxel_array, 2.0,
2100
- ... custom_meshes=meshes,
2101
- ... value_name='co2_flux',
2102
- ... colormap='RdYlBu_r',
2103
- ... nan_color='lightgray',
2104
- ... distance_factor=1.5,
2105
- ... output_file_name='co2_analysis'
2106
- ... )
2107
-
2108
- See Also:
2109
- ---------
2110
- - visualize_building_sim_results(): Specialized function for building simulations
2111
- - visualize_voxcity_multi_view(): Basic voxel visualization without custom meshes
2112
- - create_multi_view_scene(): Lower-level rendering function
2113
- """
2114
- # Setup offscreen rendering only when needed and supported
2115
- if sys.platform.startswith('linux'):
2116
- if 'DISPLAY' not in os.environ:
2117
- os.system('Xvfb :99 -screen 0 1024x768x24 > /dev/null 2>&1 &')
2118
- os.environ['DISPLAY'] = ':99'
2119
-
2120
- # Configure PyVista settings
2121
- pv.set_plot_theme('document')
2122
- pv.global_theme.background = 'white'
2123
- window_width = kwargs.get("window_width", 1024)
2124
- window_height = kwargs.get("window_height", 768)
2125
- pv.global_theme.window_size = [window_width, window_height]
2126
- pv.global_theme.jupyter_backend = 'static'
2127
-
2128
- # Parse kwargs
2129
- voxel_color_map = kwargs.get("voxel_color_map", 'default')
2130
- vox_dict = get_voxel_color_map(voxel_color_map)
2131
- output_directory = kwargs.get("output_directory", 'output')
2132
- base_filename = kwargs.get("output_file_name", None)
2133
- sim_grid = kwargs.get("sim_grid", None)
2134
- dem_grid_ori = kwargs.get("dem_grid", None)
2135
- if dem_grid_ori is not None:
2136
- dem_grid = dem_grid_ori - np.min(dem_grid_ori)
2137
- z_offset = kwargs.get("view_point_height", 1.5)
2138
- cmap_name = kwargs.get("colormap", "viridis")
2139
- vmin = kwargs.get("vmin", None)
2140
- vmax = kwargs.get("vmax", None)
2141
- projection_type = kwargs.get("projection_type", "perspective")
2142
- distance_factor = kwargs.get("distance_factor", 1.0)
2143
- colorbar_title = kwargs.get("colorbar_title", "")
2144
- value_name = kwargs.get("value_name", None)
2145
- nan_color = kwargs.get("nan_color", "gray")
2146
- show_views = kwargs.get("show_views", True)
2147
- save_obj = kwargs.get("save_obj", False)
2148
- include_classes = kwargs.get("include_classes", None)
2149
- exclude_classes = kwargs.get("exclude_classes", None)
2150
- copy_custom_mesh = kwargs.get("copy_custom_mesh", False)
2151
-
2152
- if value_name is None:
2153
- print("Set value_name")
2154
-
2155
- # Create meshes from voxel data
2156
- print("Creating voxel meshes...")
2157
- # Skip generating voxel meshes for classes that will be replaced by custom meshes
2158
- if exclude_classes is None and custom_meshes is not None:
2159
- exclude_classes = list(custom_meshes.keys())
2160
- meshes = create_city_meshes(
2161
- voxel_array,
2162
- vox_dict,
2163
- meshsize=meshsize,
2164
- include_classes=include_classes,
2165
- exclude_classes=exclude_classes,
2166
- )
2167
-
2168
- # Replace specific voxel class meshes with custom simulation meshes
2169
- if custom_meshes is not None:
2170
- for class_id, custom_mesh in custom_meshes.items():
2171
- # Apply coloring to custom meshes if they have metadata values
2172
- if hasattr(custom_mesh, 'metadata') and value_name in custom_mesh.metadata:
2173
- # Create a colored copy of the mesh for visualization
2174
- import matplotlib.cm as cm
2175
- import matplotlib.colors as mcolors
2176
-
2177
- # Get values from metadata
2178
- values = np.asarray(custom_mesh.metadata[value_name])
2179
-
2180
- # Set vmin/vmax if not provided
2181
- finite_mask = np.isfinite(values)
2182
- if not np.any(finite_mask):
2183
- local_vmin = 0.0 if vmin is None else vmin
2184
- local_vmax = 1.0 if vmax is None else vmax
2185
- else:
2186
- local_vmin = vmin if vmin is not None else float(np.nanmin(values[finite_mask]))
2187
- local_vmax = vmax if vmax is not None else float(np.nanmax(values[finite_mask]))
2188
-
2189
- # Create colors
2190
- cmap = cm.get_cmap(cmap_name)
2191
- norm = mcolors.Normalize(vmin=local_vmin, vmax=local_vmax)
2192
-
2193
- # Handle NaN values with custom color
2194
- face_colors = np.zeros((len(values), 4), dtype=float)
2195
-
2196
- # Convert string color to RGBA if needed
2197
- if isinstance(nan_color, str):
2198
- import matplotlib.colors as mcolors
2199
- nan_rgba = np.array(mcolors.to_rgba(nan_color))
2200
- else:
2201
- # Assume it's already a tuple/list of RGBA values
2202
- nan_rgba = np.array(nan_color)
2203
-
2204
- # Apply colors: NaN values get nan_color, others get colormap colors
2205
- nan_mask = ~finite_mask
2206
- face_colors[~nan_mask] = cmap(norm(values[~nan_mask]))
2207
- face_colors[nan_mask] = nan_rgba
2208
-
2209
- # Create a copy with colors
2210
- vis_mesh = custom_mesh.copy() if copy_custom_mesh else custom_mesh
2211
- vis_mesh.visual.face_colors = face_colors
2212
-
2213
- # Prepare the colormap and create colorbar if views will be shown
2214
- if show_views:
2215
- norm = mcolors.Normalize(vmin=local_vmin, vmax=local_vmax)
2216
- scalar_map = cm.ScalarMappable(norm=norm, cmap=cmap_name)
2217
- fig, ax = plt.subplots(figsize=(6, 1))
2218
- cbar = plt.colorbar(scalar_map, cax=ax, orientation='horizontal')
2219
- if colorbar_title:
2220
- cbar.set_label(colorbar_title)
2221
- plt.tight_layout()
2222
- plt.show()
2223
- plt.close(fig)
2224
-
2225
- if class_id in meshes:
2226
- print(f"Replacing voxel class {class_id} with colored custom simulation mesh")
2227
- meshes[class_id] = vis_mesh
2228
- else:
2229
- print(f"Adding colored custom simulation mesh for class {class_id}")
2230
- meshes[class_id] = vis_mesh
2231
- else:
2232
- # No metadata values, use the mesh as is
2233
- if class_id in meshes:
2234
- print(f"Replacing voxel class {class_id} with custom simulation mesh")
2235
- meshes[class_id] = custom_mesh
2236
- else:
2237
- print(f"Adding custom simulation mesh for class {class_id}")
2238
- meshes[class_id] = custom_mesh
2239
-
2240
- # Create sim_grid surface mesh if provided
2241
- if sim_grid is not None and dem_grid is not None:
2242
- print("Creating sim_grid surface mesh...")
2243
-
2244
- # If vmin/vmax not provided, use actual min/max of the valid sim data
2245
- if vmin is None:
2246
- vmin = np.nanmin(sim_grid)
2247
- if vmax is None:
2248
- vmax = np.nanmax(sim_grid)
2249
-
2250
- sim_mesh = create_sim_surface_mesh(
2251
- sim_grid, dem_grid,
2252
- meshsize=meshsize,
2253
- z_offset=z_offset,
2254
- cmap_name=cmap_name,
2255
- vmin=vmin,
2256
- vmax=vmax,
2257
- nan_color=nan_color # Pass nan_color to the mesh creation
2258
- )
2259
- if sim_mesh is not None:
2260
- meshes["sim_surface"] = sim_mesh
2261
-
2262
- # # Prepare the colormap and create colorbar
2263
- # norm = mcolors.Normalize(vmin=vmin, vmax=vmax)
2264
- # scalar_map = cm.ScalarMappable(norm=norm, cmap=cmap_name)
2265
-
2266
- # # Create a figure and axis for the colorbar but don't display
2267
- # fig, ax = plt.subplots(figsize=(6, 1))
2268
- # cbar = plt.colorbar(scalar_map, cax=ax, orientation='horizontal')
2269
- # if colorbar_title:
2270
- # cbar.set_label(colorbar_title)
2271
- # plt.tight_layout()
2272
- # plt.show()
2273
-
2274
- # # Export if filename provided
2275
- # if base_filename is not None:
2276
- # print(f"Exporting files to '{base_filename}.*' ...")
2277
- # # Create output directory if it doesn't exist
2278
- # os.makedirs(output_directory, exist_ok=True)
2279
- # export_meshes(meshes, output_directory, base_filename)
2280
-
2281
- # Create output directory if it doesn't exist
2282
- os.makedirs(output_directory, exist_ok=True)
2283
-
2284
- # After creating the meshes and before visualization
2285
- if save_obj:
2286
- output_directory = kwargs.get('output_directory', 'output')
2287
- output_file_name = kwargs.get('output_file_name', 'voxcity_mesh')
2288
- # Default: do NOT quantize to preserve exact face colors like in images
2289
- max_materials = kwargs.get('max_materials', None)
2290
- if max_materials is None:
2291
- obj_path, mtl_path = save_obj_from_colored_mesh(meshes, output_directory, output_file_name)
2292
- else:
2293
- obj_path, mtl_path = save_obj_from_colored_mesh(meshes, output_directory, output_file_name, max_materials=max_materials)
2294
- print(f"Saved mesh files to:\n {obj_path}\n {mtl_path}")
2295
-
2296
- if show_views:
2297
- # Create and save multiple views
2298
- print("Creating multiple views...")
2299
- image_files = create_multi_view_scene(meshes, output_directory=output_directory,
2300
- projection_type=projection_type,
2301
- distance_factor=distance_factor)
2302
-
2303
- # Display each view separately
2304
- for view_name, img_file in image_files:
2305
- plt.figure(figsize=(24, 16))
2306
- img = plt.imread(img_file)
2307
- plt.imshow(img)
2308
- plt.title(view_name.replace('_', ' ').title(), pad=20)
2309
- plt.axis('off')
2310
- plt.show()
2311
- plt.close()
2312
-
2313
-
2314
- def visualize_building_sim_results(voxel_array, meshsize, building_sim_mesh, **kwargs):
2315
- """
2316
- Visualize building simulation results by replacing building meshes in the original model.
2317
-
2318
- This is a specialized wrapper around visualize_voxcity_with_sim_meshes that specifically
2319
- targets building simulation meshes (assuming building class ID is -3).
2320
-
2321
- Parameters
2322
- ----------
2323
- voxel_array : np.ndarray
2324
- 3D array of voxel values.
2325
- meshsize : float
2326
- Size of each voxel in meters.
2327
- building_sim_mesh : trimesh.Trimesh
2328
- Simulation result mesh for buildings with values stored in metadata.
2329
- **kwargs:
2330
- Same parameters as visualize_voxcity_with_sim_meshes.
2331
- Additional parameters:
2332
- value_name : str
2333
- Name of the field in metadata containing values to visualize (default: 'svf_values')
2334
- nan_color : str or tuple
2335
- Color for NaN values (default: 'gray')
2336
-
2337
- Returns
2338
- -------
2339
- list
2340
- List of (view_name, image_file_path) tuples for the generated views.
2341
- """
2342
- # Building class ID is typically -3 in voxcity
2343
- building_class_id = kwargs.get("building_class_id", -3)
2344
-
2345
- # Create custom meshes dictionary with the building simulation mesh
2346
- custom_meshes = {building_class_id: building_sim_mesh}
2347
-
2348
- # Performance: skip voxel mesh generation for building class by default
2349
- if "include_classes" not in kwargs and "exclude_classes" not in kwargs:
2350
- kwargs["exclude_classes"] = [building_class_id]
2351
-
2352
- # Memory-safety: do not duplicate the provided mesh unless requested
2353
- kwargs.setdefault("copy_custom_mesh", False)
2354
-
2355
- # Add colorbar title if not provided
2356
- if "colorbar_title" not in kwargs:
2357
- # Try to guess a title based on the mesh name/type
2358
- if hasattr(building_sim_mesh, 'name') and building_sim_mesh.name:
2359
- kwargs["colorbar_title"] = building_sim_mesh.name
2360
- else:
2361
- # Use value_field name as fallback
2362
- value_name = kwargs.get("value_name", "svf_values")
2363
- pretty_name = value_name.replace('_', ' ').title()
2364
- kwargs["colorbar_title"] = pretty_name
2365
-
2366
- # Call the more general visualization function
2367
- visualize_voxcity_with_sim_meshes(
2368
- voxel_array,
2369
- meshsize,
2370
- custom_meshes=custom_meshes,
2371
- **kwargs
2372
- )
2373
-
2374
-
2375
- def _rgb_tuple_to_plotly_color(rgb_tuple):
2376
- """
2377
- Convert [R, G, B] or (R, G, B) with 0-255 range to plotly 'rgb(r,g,b)' string.
2378
- """
2379
- try:
2380
- r, g, b = rgb_tuple
2381
- r = int(max(0, min(255, r)))
2382
- g = int(max(0, min(255, g)))
2383
- b = int(max(0, min(255, b)))
2384
- return f"rgb({r},{g},{b})"
2385
- except Exception:
2386
- return "rgb(128,128,128)"
2387
-
2388
- def _mpl_cmap_to_plotly_colorscale(cmap_name, n=256):
2389
- """
2390
- Convert a matplotlib colormap name to a Plotly colorscale list.
2391
- """
2392
- try:
2393
- cmap = cm.get_cmap(cmap_name)
2394
- except Exception:
2395
- cmap = cm.get_cmap('viridis')
2396
- if n < 2:
2397
- n = 2
2398
- scale = []
2399
- for i in range(n):
2400
- x = i / (n - 1)
2401
- r, g, b, _ = cmap(x)
2402
- scale.append([x, f"rgb({int(255*r)},{int(255*g)},{int(255*b)})"])
2403
- return scale
2404
-
2405
-
2406
- def visualize_voxcity_plotly(
2407
- voxel_array,
2408
- meshsize,
2409
- classes=None,
2410
- voxel_color_map='default',
2411
- opacity=1.0,
2412
- max_dimension=160,
2413
- downsample=None,
2414
- title=None,
2415
- width=1000,
2416
- height=800,
2417
- show=True,
2418
- return_fig=False,
2419
- # Building simulation overlay
2420
- building_sim_mesh=None,
2421
- building_value_name='svf_values',
2422
- building_colormap='viridis',
2423
- building_vmin=None,
2424
- building_vmax=None,
2425
- building_nan_color='gray',
2426
- building_opacity=1.0,
2427
- building_shaded=False,
2428
- render_voxel_buildings=False,
2429
- # Ground simulation surface overlay
2430
- ground_sim_grid=None,
2431
- ground_dem_grid=None,
2432
- ground_z_offset=None,
2433
- ground_view_point_height=None,
2434
- ground_colormap='viridis',
2435
- ground_vmin=None,
2436
- ground_vmax=None,
2437
- sim_surface_opacity=0.95,
2438
- ground_shaded=False,
2439
- ):
2440
- """
2441
- Interactive 3D visualization using Plotly Mesh3d which can render:
2442
- - Voxel cubes (one Mesh3d trace per exposed face per class)
2443
- - Optional building-surface simulation mesh overlay
2444
- - Optional ground-level simulation surface overlay (triangulated)
2445
-
2446
- Parameters
2447
- ----------
2448
- voxel_array : np.ndarray (nx, ny, nz)
2449
- Voxel class grid. Required for voxel rendering; can be None if only overlays are shown.
2450
- meshsize : float
2451
- Cell size (m) for converting voxel indices to metric coordinates.
2452
- classes : list[int] or None
2453
- Classes to render for voxel cubes. Default: all non-zero present in the array.
2454
- voxel_color_map : str or dict
2455
- Scheme name understood by get_voxel_color_map, or mapping {class_id: [R,G,B]}.
2456
- opacity : float
2457
- Opacity for voxel cubes.
2458
- max_dimension : int
2459
- Target maximum dimension for auto-downsampling.
2460
- downsample : int or None
2461
- Explicit stride for voxels. If None, auto-downsample when needed.
2462
- title, width, height, show, return_fig : standard display controls.
2463
-
2464
- Building overlay
2465
- ----------------
2466
- building_sim_mesh : trimesh.Trimesh or similar with .vertices, .faces
2467
- building_value_name : str (default 'svf_values')
2468
- building_colormap : str (default 'viridis')
2469
- building_vmin, building_vmax : float or None
2470
- building_nan_color : str (default 'gray')
2471
- building_opacity : float (default 1.0)
2472
- building_shaded : bool (default False)
2473
- render_voxel_buildings : bool (default False; when False, exclude class -3 from voxel cubes)
2474
-
2475
- Ground overlay
2476
- --------------
2477
- ground_sim_grid : 2D array of values
2478
- ground_dem_grid : 2D array of ground elevations
2479
- ground_z_offset or ground_view_point_height : float above DEM (default 1.5 m)
2480
- ground_colormap : str (default 'viridis')
2481
- ground_vmin, ground_vmax : float or None
2482
- sim_surface_opacity : float (default 0.95)
2483
- ground_shaded : bool (default False)
2484
- """
2485
- if voxel_array is None or getattr(voxel_array, 'ndim', 0) != 3:
2486
- # Allow overlays without voxels
2487
- if building_sim_mesh is None and (ground_sim_grid is None or ground_dem_grid is None):
2488
- raise ValueError("voxel_array must be a 3D numpy array (nx, ny, nz) when no overlays are provided")
2489
- vox = None
2490
- else:
2491
- vox = voxel_array
2492
-
2493
- # Downsample for performance if requested or auto-needed
2494
- # Respect explicit downsample even when it is 1 (no auto-downsample)
2495
- stride = 1
2496
- if vox is not None:
2497
- if downsample is not None:
2498
- stride = max(1, int(downsample))
2499
- else:
2500
- nx_tmp, ny_tmp, nz_tmp = vox.shape
2501
- max_dim = max(nx_tmp, ny_tmp, nz_tmp)
2502
- if max_dim > max_dimension:
2503
- stride = int(np.ceil(max_dim / max_dimension))
2504
- if stride > 1:
2505
- vox = vox[::stride, ::stride, ::stride]
2506
-
2507
- nx, ny, nz = vox.shape
2508
-
2509
- # Coordinate of voxel centers in meters
2510
- dx = meshsize * stride
2511
- dy = meshsize * stride
2512
- dz = meshsize * stride
2513
- x = np.arange(nx, dtype=float) * dx
2514
- y = np.arange(ny, dtype=float) * dy
2515
- z = np.arange(nz, dtype=float) * dz
2516
-
2517
- # Choose classes
2518
- if classes is None:
2519
- classes_all = np.unique(vox[vox != 0]).tolist()
2520
- else:
2521
- classes_all = list(classes)
2522
- # Exclude building voxels (-3) only when a building overlay is provided and hiding is desired
2523
- if building_sim_mesh is not None and getattr(building_sim_mesh, 'vertices', None) is not None:
2524
- if render_voxel_buildings:
2525
- classes_to_draw = classes_all
2526
- else:
2527
- classes_to_draw = [c for c in classes_all if int(c) != -3]
2528
- else:
2529
- classes_to_draw = classes_all
2530
-
2531
- # Resolve color map: accept scheme name or explicit dict
2532
- if isinstance(voxel_color_map, dict):
2533
- vox_dict = voxel_color_map
2534
- else:
2535
- vox_dict = get_voxel_color_map(voxel_color_map)
2536
-
2537
- def exposed_face_masks(occ):
2538
- # occ shape (nx, ny, nz)
2539
- p = np.pad(occ, ((0,1),(0,0),(0,0)), constant_values=False)
2540
- posx = occ & (~p[1:,:,:])
2541
- p = np.pad(occ, ((1,0),(0,0),(0,0)), constant_values=False)
2542
- negx = occ & (~p[:-1,:,:])
2543
- p = np.pad(occ, ((0,0),(0,1),(0,0)), constant_values=False)
2544
- posy = occ & (~p[:,1:,:])
2545
- p = np.pad(occ, ((0,0),(1,0),(0,0)), constant_values=False)
2546
- negy = occ & (~p[:,:-1,:])
2547
- p = np.pad(occ, ((0,0),(0,0),(0,1)), constant_values=False)
2548
- posz = occ & (~p[:,:,1:])
2549
- p = np.pad(occ, ((0,0),(0,0),(1,0)), constant_values=False)
2550
- negz = occ & (~p[:,:,:-1])
2551
- return posx, negx, posy, negy, posz, negz
2552
-
2553
- def add_faces(fig, mask, plane, color_rgb):
2554
- idx = np.argwhere(mask)
2555
- if idx.size == 0:
2556
- return
2557
- xi, yi, zi = idx[:,0], idx[:,1], idx[:,2]
2558
- xc = x[xi]; yc = y[yi]; zc = z[zi]
2559
- x0, x1 = xc - dx/2.0, xc + dx/2.0
2560
- y0, y1 = yc - dy/2.0, yc + dy/2.0
2561
- z0, z1 = zc - dz/2.0, zc + dz/2.0
2562
-
2563
- if plane == '+x':
2564
- verts = np.column_stack([
2565
- np.repeat(x1, 4),
2566
- np.stack([y0, y1, y1, y0], axis=1).reshape(-1)[0::4].repeat(4) # placeholder, will rebuild below
2567
- ])
2568
- # Build vertices for all faces vectorized per plane
2569
- if plane == '+x':
2570
- vx = np.stack([x1, x1, x1, x1], axis=1)
2571
- vy = np.stack([y0, y1, y1, y0], axis=1)
2572
- vz = np.stack([z0, z0, z1, z1], axis=1)
2573
- elif plane == '-x':
2574
- vx = np.stack([x0, x0, x0, x0], axis=1)
2575
- vy = np.stack([y0, y1, y1, y0], axis=1)
2576
- vz = np.stack([z1, z1, z0, z0], axis=1)
2577
- elif plane == '+y':
2578
- vx = np.stack([x0, x1, x1, x0], axis=1)
2579
- vy = np.stack([y1, y1, y1, y1], axis=1)
2580
- vz = np.stack([z0, z0, z1, z1], axis=1)
2581
- elif plane == '-y':
2582
- vx = np.stack([x0, x1, x1, x0], axis=1)
2583
- vy = np.stack([y0, y0, y0, y0], axis=1)
2584
- vz = np.stack([z1, z1, z0, z0], axis=1)
2585
- elif plane == '+z':
2586
- vx = np.stack([x0, x1, x1, x0], axis=1)
2587
- vy = np.stack([y0, y0, y1, y1], axis=1)
2588
- vz = np.stack([z1, z1, z1, z1], axis=1)
2589
- elif plane == '-z':
2590
- vx = np.stack([x0, x1, x1, x0], axis=1)
2591
- vy = np.stack([y1, y1, y0, y0], axis=1)
2592
- vz = np.stack([z0, z0, z0, z0], axis=1)
2593
- else:
2594
- return
2595
-
2596
- V = np.column_stack([vx.reshape(-1), vy.reshape(-1), vz.reshape(-1)])
2597
- # Two triangles per quad
2598
- n = idx.shape[0]
2599
- starts = np.arange(0, 4*n, 4, dtype=np.int32)
2600
- tris = np.vstack([
2601
- np.stack([starts, starts+1, starts+2], axis=1),
2602
- np.stack([starts, starts+2, starts+3], axis=1)
2603
- ])
2604
-
2605
- # Lighting for shading contrast (higher ambient + diffuse + specular)
2606
- lighting = dict(
2607
- ambient=0.35,
2608
- diffuse=1.0,
2609
- specular=0.4,
2610
- roughness=0.5,
2611
- fresnel=0.1,
2612
- )
2613
- # Directional light position near the scene center
2614
- cx = (x.min() + x.max()) * 0.5 if len(x) > 0 else 0.0
2615
- cy = (y.min() + y.max()) * 0.5 if len(y) > 0 else 0.0
2616
- cz = (z.min() + z.max()) * 0.5 if len(z) > 0 else 0.0
2617
- lx = cx + (x.max() - x.min() + dx) * 0.9
2618
- ly = cy + (y.max() - y.min() + dy) * 0.6
2619
- lz = cz + (z.max() - z.min() + dz) * 1.4
2620
-
2621
- fig.add_trace(
2622
- go.Mesh3d(
2623
- x=V[:,0], y=V[:,1], z=V[:,2],
2624
- i=tris[:,0], j=tris[:,1], k=tris[:,2],
2625
- color=_rgb_tuple_to_plotly_color(color_rgb),
2626
- opacity=float(opacity),
2627
- flatshading=False,
2628
- lighting=lighting,
2629
- lightposition=dict(x=lx, y=ly, z=lz),
2630
- name=f"{plane}"
2631
- )
2632
- )
2633
-
2634
- fig = go.Figure()
2635
-
2636
- # Draw voxel cubes if available
2637
- if vox is not None and classes_to_draw:
2638
- for cls in classes_to_draw:
2639
- if not np.any(vox == cls):
2640
- continue
2641
- occ = (vox == cls)
2642
- posx, negx, posy, negy, posz, negz = exposed_face_masks(occ)
2643
- color_rgb = vox_dict.get(int(cls), [128,128,128])
2644
- add_faces(fig, posx, '+x', color_rgb)
2645
- add_faces(fig, negx, '-x', color_rgb)
2646
- add_faces(fig, posy, '+y', color_rgb)
2647
- add_faces(fig, negy, '-y', color_rgb)
2648
- add_faces(fig, posz, '+z', color_rgb)
2649
- add_faces(fig, negz, '-z', color_rgb)
2650
-
2651
- # Building simulation mesh overlay
2652
- if building_sim_mesh is not None and getattr(building_sim_mesh, 'vertices', None) is not None:
2653
- Vb = np.asarray(building_sim_mesh.vertices)
2654
- Fb = np.asarray(building_sim_mesh.faces)
2655
-
2656
- # Values can be stored in metadata under building_value_name
2657
- values = None
2658
- if hasattr(building_sim_mesh, 'metadata') and isinstance(building_sim_mesh.metadata, dict):
2659
- values = building_sim_mesh.metadata.get(building_value_name)
2660
- if values is not None:
2661
- values = np.asarray(values)
2662
-
2663
- face_vals = None
2664
- if values is not None and values.size == len(Fb):
2665
- face_vals = values.astype(float)
2666
- elif values is not None and values.size == len(Vb):
2667
- vals_v = values.astype(float)
2668
- face_vals = np.nanmean(vals_v[Fb], axis=1)
2669
-
2670
- facecolor = None
2671
- if face_vals is not None:
2672
- finite = np.isfinite(face_vals)
2673
- vmin_b = building_vmin if building_vmin is not None else (float(np.nanmin(face_vals[finite])) if np.any(finite) else 0.0)
2674
- vmax_b = building_vmax if building_vmax is not None else (float(np.nanmax(face_vals[finite])) if np.any(finite) else 1.0)
2675
- norm = mcolors.Normalize(vmin=vmin_b, vmax=vmax_b)
2676
- cmap = cm.get_cmap(building_colormap)
2677
- colors_rgba = np.zeros((len(Fb), 4), dtype=float)
2678
- colors_rgba[finite] = cmap(norm(face_vals[finite]))
2679
- nan_rgba = np.array(mcolors.to_rgba(building_nan_color))
2680
- colors_rgba[~finite] = nan_rgba
2681
- facecolor = [f"rgb({int(255*c[0])},{int(255*c[1])},{int(255*c[2])})" for c in colors_rgba]
2682
-
2683
- if building_shaded:
2684
- lighting_b = dict(ambient=0.35, diffuse=1.0, specular=0.4, roughness=0.5, fresnel=0.1)
2685
- flat_b = False
2686
- else:
2687
- lighting_b = dict(ambient=1.0, diffuse=0.0, specular=0.0, roughness=0.0, fresnel=0.0)
2688
- flat_b = False
2689
-
2690
- # Place a directional light near mesh center
2691
- cx = float((Vb[:,0].min() + Vb[:,0].max()) * 0.5)
2692
- cy = float((Vb[:,1].min() + Vb[:,1].max()) * 0.5)
2693
- cz = float((Vb[:,2].min() + Vb[:,2].max()) * 0.5)
2694
- lx = cx + (Vb[:,0].max() - Vb[:,0].min() + meshsize) * 0.9
2695
- ly = cy + (Vb[:,1].max() - Vb[:,1].min() + meshsize) * 0.6
2696
- lz = cz + (Vb[:,2].max() - Vb[:,2].min() + meshsize) * 1.4
2697
-
2698
- fig.add_trace(
2699
- go.Mesh3d(
2700
- x=Vb[:,0], y=Vb[:,1], z=Vb[:,2],
2701
- i=Fb[:,0], j=Fb[:,1], k=Fb[:,2],
2702
- facecolor=facecolor if facecolor is not None else None,
2703
- color=None if facecolor is not None else 'rgb(200,200,200)',
2704
- opacity=float(building_opacity),
2705
- flatshading=flat_b,
2706
- lighting=lighting_b,
2707
- lightposition=dict(x=lx, y=ly, z=lz),
2708
- name=building_value_name if facecolor is not None else 'building_mesh'
2709
- )
2710
- )
2711
-
2712
- # Colorbar for building overlay
2713
- if face_vals is not None:
2714
- colorscale_b = _mpl_cmap_to_plotly_colorscale(building_colormap)
2715
- fig.add_trace(
2716
- go.Scatter3d(
2717
- x=[None], y=[None], z=[None],
2718
- mode='markers',
2719
- marker=dict(
2720
- size=0.1,
2721
- color=[vmin_b, vmax_b],
2722
- colorscale=colorscale_b,
2723
- cmin=vmin_b,
2724
- cmax=vmax_b,
2725
- colorbar=dict(title=building_value_name, len=0.5, y=0.8),
2726
- showscale=True,
2727
- ),
2728
- showlegend=False,
2729
- hoverinfo='skip',
2730
- )
2731
- )
2732
-
2733
- # Ground simulation surface overlay
2734
- if ground_sim_grid is not None and ground_dem_grid is not None:
2735
- sim_vals = np.asarray(ground_sim_grid, dtype=float)
2736
- finite = np.isfinite(sim_vals)
2737
- vmin_g = ground_vmin if ground_vmin is not None else (float(np.nanmin(sim_vals[finite])) if np.any(finite) else 0.0)
2738
- vmax_g = ground_vmax if ground_vmax is not None else (float(np.nanmax(sim_vals[finite])) if np.any(finite) else 1.0)
2739
-
2740
- # Determine z offset
2741
- z_off = ground_z_offset if ground_z_offset is not None else ground_view_point_height
2742
- try:
2743
- z_off = float(z_off) if z_off is not None else 1.5
2744
- except Exception:
2745
- z_off = 1.5
2746
- if meshsize is not None:
2747
- try:
2748
- ms = float(meshsize)
2749
- if z_off < ms:
2750
- z_off = ms
2751
- z_off = ms * math.ceil(z_off / ms)
2752
- except Exception:
2753
- pass
2754
-
2755
- # Normalize DEM so its minimum becomes 0, matching voxel Z coordinates
2756
- try:
2757
- dem_norm = np.asarray(ground_dem_grid, dtype=float)
2758
- dem_norm = dem_norm - np.nanmin(dem_norm)
2759
- except Exception:
2760
- dem_norm = ground_dem_grid
2761
-
2762
- sim_mesh = create_sim_surface_mesh(
2763
- ground_sim_grid,
2764
- dem_norm,
2765
- meshsize=meshsize,
2766
- z_offset=z_off,
2767
- cmap_name=ground_colormap,
2768
- vmin=vmin_g,
2769
- vmax=vmax_g,
2770
- )
2771
-
2772
- if sim_mesh is not None and getattr(sim_mesh, 'vertices', None) is not None:
2773
- V = np.asarray(sim_mesh.vertices)
2774
- F = np.asarray(sim_mesh.faces)
2775
-
2776
- facecolor = None
2777
- try:
2778
- colors_rgba = np.asarray(sim_mesh.visual.face_colors)
2779
- if colors_rgba.ndim == 2 and colors_rgba.shape[0] == len(F):
2780
- facecolor = [f"rgb({int(c[0])},{int(c[1])},{int(c[2])})" for c in colors_rgba]
2781
- except Exception:
2782
- facecolor = None
2783
-
2784
- if ground_shaded:
2785
- lighting = dict(ambient=0.35, diffuse=1.0, specular=0.4, roughness=0.5, fresnel=0.1)
2786
- flat = False
2787
- else:
2788
- lighting = dict(ambient=1.0, diffuse=0.0, specular=0.0, roughness=0.0, fresnel=0.0)
2789
- flat = False
2790
-
2791
- cx = float((V[:,0].min() + V[:,0].max()) * 0.5)
2792
- cy = float((V[:,1].min() + V[:,1].max()) * 0.5)
2793
- cz = float((V[:,2].min() + V[:,2].max()) * 0.5)
2794
- lx = cx + (V[:,0].max() - V[:,0].min() + meshsize) * 0.9
2795
- ly = cy + (V[:,1].max() - V[:,1].min() + meshsize) * 0.6
2796
- lz = cz + (V[:,2].max() - V[:,2].min() + meshsize) * 1.4
2797
-
2798
- fig.add_trace(
2799
- go.Mesh3d(
2800
- x=V[:,0], y=V[:,1], z=V[:,2],
2801
- i=F[:,0], j=F[:,1], k=F[:,2],
2802
- facecolor=facecolor,
2803
- color=None if facecolor is not None else 'rgb(200,200,200)',
2804
- opacity=float(sim_surface_opacity),
2805
- flatshading=flat,
2806
- lighting=lighting,
2807
- lightposition=dict(x=lx, y=ly, z=lz),
2808
- name='sim_surface'
2809
- )
2810
- )
2811
-
2812
- # Colorbar for ground overlay
2813
- colorscale_g = _mpl_cmap_to_plotly_colorscale(ground_colormap)
2814
- fig.add_trace(
2815
- go.Scatter3d(
2816
- x=[None], y=[None], z=[None],
2817
- mode='markers',
2818
- marker=dict(
2819
- size=0.1,
2820
- color=[vmin_g, vmax_g],
2821
- colorscale=colorscale_g,
2822
- cmin=vmin_g,
2823
- cmax=vmax_g,
2824
- colorbar=dict(title='ground', len=0.5, y=0.2),
2825
- showscale=True,
2826
- ),
2827
- showlegend=False,
2828
- hoverinfo='skip',
2829
- )
2830
- )
2831
-
2832
- fig.update_layout(
2833
- title=title or "VoxCity 3D",
2834
- width=width,
2835
- height=height,
2836
- scene=dict(
2837
- xaxis_title="X (m)",
2838
- yaxis_title="Y (m)",
2839
- zaxis_title="Z (m)",
2840
- aspectmode="data",
2841
- camera=dict(eye=dict(x=1.6, y=1.6, z=1.0)),
2842
- )
2843
- )
2844
-
2845
- if show:
2846
- fig.show()
2847
- if return_fig:
2848
- return fig
2849
- return None