voxcity 1.0.2__py3-none-any.whl → 1.0.15__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.
- voxcity/downloader/ocean.py +559 -0
- voxcity/generator/api.py +6 -0
- voxcity/generator/grids.py +45 -32
- voxcity/generator/pipeline.py +327 -27
- voxcity/geoprocessor/draw.py +14 -8
- voxcity/geoprocessor/raster/__init__.py +2 -0
- voxcity/geoprocessor/raster/core.py +31 -0
- voxcity/geoprocessor/raster/landcover.py +173 -49
- voxcity/geoprocessor/raster/raster.py +1 -1
- voxcity/models.py +2 -0
- voxcity/simulator/solar/__init__.py +13 -0
- voxcity/simulator_gpu/__init__.py +90 -0
- voxcity/simulator_gpu/core.py +322 -0
- voxcity/simulator_gpu/domain.py +36 -0
- voxcity/simulator_gpu/init_taichi.py +154 -0
- voxcity/simulator_gpu/raytracing.py +776 -0
- voxcity/simulator_gpu/solar/__init__.py +222 -0
- voxcity/simulator_gpu/solar/core.py +66 -0
- voxcity/simulator_gpu/solar/csf.py +1249 -0
- voxcity/simulator_gpu/solar/domain.py +618 -0
- voxcity/simulator_gpu/solar/epw.py +421 -0
- voxcity/simulator_gpu/solar/integration.py +4322 -0
- voxcity/simulator_gpu/solar/mask.py +459 -0
- voxcity/simulator_gpu/solar/radiation.py +3019 -0
- voxcity/simulator_gpu/solar/raytracing.py +182 -0
- voxcity/simulator_gpu/solar/reflection.py +533 -0
- voxcity/simulator_gpu/solar/sky.py +907 -0
- voxcity/simulator_gpu/solar/solar.py +337 -0
- voxcity/simulator_gpu/solar/svf.py +446 -0
- voxcity/simulator_gpu/solar/volumetric.py +2099 -0
- voxcity/simulator_gpu/visibility/__init__.py +109 -0
- voxcity/simulator_gpu/visibility/geometry.py +278 -0
- voxcity/simulator_gpu/visibility/integration.py +808 -0
- voxcity/simulator_gpu/visibility/landmark.py +753 -0
- voxcity/simulator_gpu/visibility/view.py +944 -0
- voxcity/visualizer/renderer.py +2 -1
- {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/METADATA +16 -53
- {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/RECORD +41 -16
- {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/WHEEL +0 -0
- {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-1.0.2.dist-info → voxcity-1.0.15.dist-info}/licenses/LICENSE +0 -0
voxcity/generator/grids.py
CHANGED
|
@@ -46,16 +46,19 @@ def get_last_effective_land_cover_source():
|
|
|
46
46
|
|
|
47
47
|
|
|
48
48
|
def get_land_cover_grid(rectangle_vertices, meshsize, source, output_dir, print_class_info=True, **kwargs):
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
print(
|
|
49
|
+
quiet = kwargs.get('quiet', False)
|
|
50
|
+
if not quiet:
|
|
51
|
+
print("Creating Land Use Land Cover grid\n ")
|
|
52
|
+
print(f"Data source: {source}")
|
|
53
|
+
if print_class_info:
|
|
54
|
+
print(get_source_class_descriptions(source))
|
|
53
55
|
|
|
54
56
|
if source not in ["OpenStreetMap", "OpenEarthMapJapan"]:
|
|
55
57
|
try:
|
|
56
58
|
initialize_earth_engine()
|
|
57
59
|
except Exception as e:
|
|
58
|
-
|
|
60
|
+
if not quiet:
|
|
61
|
+
print("Earth Engine unavailable (", str(e), ") — falling back to OpenStreetMap for land cover.")
|
|
59
62
|
source = 'OpenStreetMap'
|
|
60
63
|
|
|
61
64
|
os.makedirs(output_dir, exist_ok=True)
|
|
@@ -70,11 +73,12 @@ def get_land_cover_grid(rectangle_vertices, meshsize, source, output_dir, print_
|
|
|
70
73
|
try:
|
|
71
74
|
image = get_ee_image_collection(collection_name, roi)
|
|
72
75
|
# If collection is empty, image operations may fail; guard with try/except
|
|
73
|
-
save_geotiff(image, geotiff_path)
|
|
76
|
+
save_geotiff(image, geotiff_path, scale=meshsize, region=roi, crs='EPSG:4326')
|
|
74
77
|
if (not os.path.exists(geotiff_path)) or (os.path.getsize(geotiff_path) == 0):
|
|
75
78
|
raise RuntimeError("Urbanwatch export produced no file")
|
|
76
79
|
except Exception as e:
|
|
77
|
-
|
|
80
|
+
if not quiet:
|
|
81
|
+
print("Urbanwatch coverage not found for AOI; falling back to OpenStreetMap (reason:", str(e), ")")
|
|
78
82
|
effective_source = 'OpenStreetMap'
|
|
79
83
|
land_cover_gdf = load_land_cover_gdf_from_osm(rectangle_vertices)
|
|
80
84
|
elif source == 'ESA WorldCover':
|
|
@@ -114,7 +118,11 @@ def get_land_cover_grid(rectangle_vertices, meshsize, source, output_dir, print_
|
|
|
114
118
|
|
|
115
119
|
if effective_source == 'OpenStreetMap':
|
|
116
120
|
default_class = kwargs.get('default_land_cover_class', 'Developed space')
|
|
117
|
-
|
|
121
|
+
detect_ocean = kwargs.get('detect_ocean', True) # Default True for OSM
|
|
122
|
+
land_cover_grid_str = create_land_cover_grid_from_gdf_polygon(
|
|
123
|
+
land_cover_gdf, meshsize, effective_source, rectangle_vertices,
|
|
124
|
+
default_class=default_class, detect_ocean=detect_ocean
|
|
125
|
+
)
|
|
118
126
|
else:
|
|
119
127
|
land_cover_grid_str = create_land_cover_grid_from_geotiff_polygon(geotiff_path, meshsize, land_cover_classes, rectangle_vertices)
|
|
120
128
|
|
|
@@ -137,14 +145,17 @@ def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir, b
|
|
|
137
145
|
if source in ee_required_sources:
|
|
138
146
|
initialize_earth_engine()
|
|
139
147
|
|
|
140
|
-
|
|
141
|
-
|
|
148
|
+
quiet = kwargs.get('quiet', False)
|
|
149
|
+
if not quiet:
|
|
150
|
+
print("Creating Building Height grid\n ")
|
|
151
|
+
print(f"Base data source: {source}")
|
|
142
152
|
|
|
143
153
|
os.makedirs(output_dir, exist_ok=True)
|
|
144
154
|
|
|
145
155
|
if building_gdf is not None:
|
|
146
156
|
gdf = building_gdf
|
|
147
|
-
|
|
157
|
+
if not quiet:
|
|
158
|
+
print("Using provided GeoDataFrame for building data")
|
|
148
159
|
else:
|
|
149
160
|
floor_height = kwargs.get("floor_height", 3.0)
|
|
150
161
|
if source == 'Microsoft Building Footprints':
|
|
@@ -171,7 +182,8 @@ def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir, b
|
|
|
171
182
|
building_complementary_source = kwargs.get("building_complementary_source")
|
|
172
183
|
try:
|
|
173
184
|
comp_label = building_complementary_source if building_complementary_source not in (None, "") else "None"
|
|
174
|
-
|
|
185
|
+
if not quiet:
|
|
186
|
+
print(f"Complementary data source: {comp_label}")
|
|
175
187
|
except Exception:
|
|
176
188
|
pass
|
|
177
189
|
building_complement_height = kwargs.get("building_complement_height")
|
|
@@ -189,7 +201,8 @@ def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir, b
|
|
|
189
201
|
save_geotiff_open_buildings_temporal(roi, geotiff_path_comp)
|
|
190
202
|
building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, geotiff_path_comp=geotiff_path_comp, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
|
|
191
203
|
except Exception as e:
|
|
192
|
-
|
|
204
|
+
if not quiet:
|
|
205
|
+
print("Open Building 2.5D Temporal requires Earth Engine (", str(e), ") — proceeding without complementary raster.")
|
|
193
206
|
building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
|
|
194
207
|
elif building_complementary_source in ["England 1m DSM - DTM", "Netherlands 0.5m DSM - DTM"]:
|
|
195
208
|
try:
|
|
@@ -199,7 +212,8 @@ def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir, b
|
|
|
199
212
|
save_geotiff_dsm_minus_dtm(roi, geotiff_path_comp, meshsize, building_complementary_source)
|
|
200
213
|
building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, geotiff_path_comp=geotiff_path_comp, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
|
|
201
214
|
except Exception as e:
|
|
202
|
-
|
|
215
|
+
if not quiet:
|
|
216
|
+
print("DSM-DTM complementary raster requires Earth Engine (", str(e), ") — proceeding without complementary raster.")
|
|
203
217
|
building_height_grid, building_min_height_grid, building_id_grid, filtered_buildings = create_building_height_grid_from_gdf_polygon(gdf, meshsize, rectangle_vertices, complement_height=building_complement_height, overlapping_footprint=overlapping_footprint)
|
|
204
218
|
else:
|
|
205
219
|
if building_complementary_source == 'Microsoft Building Footprints':
|
|
@@ -232,8 +246,10 @@ def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir, b
|
|
|
232
246
|
|
|
233
247
|
|
|
234
248
|
def get_canopy_height_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
|
|
235
|
-
|
|
236
|
-
|
|
249
|
+
quiet = kwargs.get('quiet', False)
|
|
250
|
+
if not quiet:
|
|
251
|
+
print("Creating Canopy Height grid\n ")
|
|
252
|
+
print(f"Data source: {source}")
|
|
237
253
|
|
|
238
254
|
os.makedirs(output_dir, exist_ok=True)
|
|
239
255
|
|
|
@@ -297,7 +313,8 @@ def get_canopy_height_grid(rectangle_vertices, meshsize, source, output_dir, **k
|
|
|
297
313
|
try:
|
|
298
314
|
initialize_earth_engine()
|
|
299
315
|
except Exception as e:
|
|
300
|
-
|
|
316
|
+
if not quiet:
|
|
317
|
+
print("Earth Engine unavailable (", str(e), ") — falling back to Static canopy heights.")
|
|
301
318
|
# Re-enter with explicit Static logic using land cover mask
|
|
302
319
|
return get_canopy_height_grid(rectangle_vertices, meshsize, 'Static', output_dir, **kwargs)
|
|
303
320
|
|
|
@@ -313,7 +330,7 @@ def get_canopy_height_grid(rectangle_vertices, meshsize, source, output_dir, **k
|
|
|
313
330
|
else:
|
|
314
331
|
raise ValueError(f"Unsupported canopy source: {source}")
|
|
315
332
|
|
|
316
|
-
save_geotiff(image, geotiff_path,
|
|
333
|
+
save_geotiff(image, geotiff_path, scale=meshsize, region=roi, crs='EPSG:4326')
|
|
317
334
|
canopy_height_grid = create_height_grid_from_geotiff_polygon(geotiff_path, meshsize, rectangle_vertices)
|
|
318
335
|
|
|
319
336
|
trunk_height_ratio = kwargs.get("trunk_height_ratio")
|
|
@@ -330,8 +347,10 @@ def get_canopy_height_grid(rectangle_vertices, meshsize, source, output_dir, **k
|
|
|
330
347
|
|
|
331
348
|
|
|
332
349
|
def get_dem_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
|
|
333
|
-
|
|
334
|
-
|
|
350
|
+
quiet = kwargs.get('quiet', False)
|
|
351
|
+
if not quiet:
|
|
352
|
+
print("Creating Digital Elevation Model (DEM) grid\n ")
|
|
353
|
+
print(f"Data source: {source}")
|
|
335
354
|
|
|
336
355
|
if source == "Local file":
|
|
337
356
|
geotiff_path = kwargs["dem_path"]
|
|
@@ -339,18 +358,12 @@ def get_dem_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
|
|
|
339
358
|
try:
|
|
340
359
|
initialize_earth_engine()
|
|
341
360
|
except Exception as e:
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
#
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
# Bypass EE path and produce zeros later
|
|
349
|
-
dem_grid = np.zeros((1, 1), dtype=float)
|
|
350
|
-
# Build shape using land cover grid shape if provided via kwargs for robustness
|
|
351
|
-
lc_like = kwargs.get("land_cover_like")
|
|
352
|
-
if lc_like is not None:
|
|
353
|
-
dem_grid = np.zeros_like(lc_like)
|
|
361
|
+
if not quiet:
|
|
362
|
+
print("Earth Engine unavailable (", str(e), ") — falling back to flat DEM.")
|
|
363
|
+
# Compute grid shape directly from rectangle_vertices and meshsize
|
|
364
|
+
from ..geoprocessor.raster.core import compute_grid_shape
|
|
365
|
+
grid_shape = compute_grid_shape(rectangle_vertices, meshsize)
|
|
366
|
+
dem_grid = np.zeros(grid_shape, dtype=float)
|
|
354
367
|
return dem_grid
|
|
355
368
|
|
|
356
369
|
geotiff_path = os.path.join(output_dir, "dem.tif")
|
voxcity/generator/pipeline.py
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import os
|
|
2
2
|
from ..utils.logging import get_logger
|
|
3
3
|
from typing import Optional
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
4
5
|
import numpy as np
|
|
5
6
|
|
|
6
7
|
from ..models import (
|
|
@@ -78,33 +79,70 @@ class VoxCityPipeline:
|
|
|
78
79
|
canopy_strategy = CanopySourceFactory.create(cfg.canopy_height_source, cfg)
|
|
79
80
|
dem_strategy = DemSourceFactory.create(cfg.dem_source)
|
|
80
81
|
|
|
81
|
-
#
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
82
|
+
# Check if parallel download is enabled
|
|
83
|
+
parallel_download = getattr(cfg, 'parallel_download', False)
|
|
84
|
+
|
|
85
|
+
if parallel_download and cfg.canopy_height_source != "Static":
|
|
86
|
+
# All 4 downloads are independent - run in parallel
|
|
87
|
+
land_cover_grid, bh, bmin, bid, building_gdf_out, canopy_top, canopy_bottom, dem, lc_src_effective = \
|
|
88
|
+
self._run_parallel_downloads(
|
|
89
|
+
cfg, land_strategy, build_strategy, canopy_strategy, dem_strategy,
|
|
90
|
+
building_gdf, terrain_gdf, kwargs
|
|
91
|
+
)
|
|
92
|
+
# Run visualizations after parallel downloads complete (if gridvis enabled)
|
|
93
|
+
if kwargs.get('gridvis', cfg.gridvis):
|
|
94
|
+
self._visualize_grids_after_parallel(
|
|
95
|
+
land_cover_grid, bh, canopy_top, dem,
|
|
96
|
+
lc_src_effective, cfg.meshsize
|
|
97
|
+
)
|
|
98
|
+
elif parallel_download and cfg.canopy_height_source == "Static":
|
|
99
|
+
# Static canopy needs land_cover_grid for tree mask
|
|
100
|
+
# Run land_cover + building + dem in parallel, then canopy sequentially
|
|
101
|
+
land_cover_grid, bh, bmin, bid, building_gdf_out, dem, lc_src_effective = \
|
|
102
|
+
self._run_parallel_downloads_static_canopy(
|
|
103
|
+
cfg, land_strategy, build_strategy, dem_strategy,
|
|
104
|
+
building_gdf, terrain_gdf, kwargs
|
|
105
|
+
)
|
|
106
|
+
# Visualize land_cover, building, dem after parallel (if gridvis enabled)
|
|
107
|
+
if kwargs.get('gridvis', cfg.gridvis):
|
|
108
|
+
self._visualize_grids_after_parallel(
|
|
109
|
+
land_cover_grid, bh, None, dem,
|
|
110
|
+
lc_src_effective, cfg.meshsize
|
|
111
|
+
)
|
|
112
|
+
# Now run canopy with land_cover_grid available (this will visualize itself)
|
|
113
|
+
canopy_top, canopy_bottom = canopy_strategy.build_grids(
|
|
114
|
+
cfg.rectangle_vertices, cfg.meshsize, land_cover_grid, cfg.output_dir,
|
|
115
|
+
land_cover_source=lc_src_effective,
|
|
116
|
+
**{**cfg.canopy_options, **kwargs}
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
# Sequential mode (original behavior)
|
|
120
|
+
land_cover_grid = land_strategy.build_grid(
|
|
121
|
+
cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
|
|
122
|
+
**{**cfg.land_cover_options, **kwargs}
|
|
123
|
+
)
|
|
124
|
+
# Detect effective land cover source (e.g., Urbanwatch -> OpenStreetMap fallback)
|
|
125
|
+
try:
|
|
126
|
+
from .grids import get_last_effective_land_cover_source
|
|
127
|
+
lc_src_effective = get_last_effective_land_cover_source() or cfg.land_cover_source
|
|
128
|
+
except Exception:
|
|
129
|
+
lc_src_effective = cfg.land_cover_source
|
|
130
|
+
bh, bmin, bid, building_gdf_out = build_strategy.build_grids(
|
|
131
|
+
cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
|
|
132
|
+
building_gdf=building_gdf,
|
|
133
|
+
**{**cfg.building_options, **kwargs}
|
|
134
|
+
)
|
|
135
|
+
canopy_top, canopy_bottom = canopy_strategy.build_grids(
|
|
136
|
+
cfg.rectangle_vertices, cfg.meshsize, land_cover_grid, cfg.output_dir,
|
|
137
|
+
land_cover_source=lc_src_effective,
|
|
138
|
+
**{**cfg.canopy_options, **kwargs}
|
|
139
|
+
)
|
|
140
|
+
dem = dem_strategy.build_grid(
|
|
141
|
+
cfg.rectangle_vertices, cfg.meshsize, land_cover_grid, cfg.output_dir,
|
|
142
|
+
terrain_gdf=terrain_gdf,
|
|
143
|
+
land_cover_like=land_cover_grid,
|
|
144
|
+
**{**cfg.dem_options, **kwargs}
|
|
145
|
+
)
|
|
108
146
|
|
|
109
147
|
ro = cfg.remove_perimeter_object
|
|
110
148
|
if (ro is not None) and (ro > 0):
|
|
@@ -152,6 +190,258 @@ class VoxCityPipeline:
|
|
|
152
190
|
},
|
|
153
191
|
)
|
|
154
192
|
|
|
193
|
+
def _visualize_grids_after_parallel(
|
|
194
|
+
self, land_cover_grid, building_height_grid, canopy_top, dem_grid,
|
|
195
|
+
land_cover_source, meshsize
|
|
196
|
+
):
|
|
197
|
+
"""
|
|
198
|
+
Run grid visualizations after parallel downloads complete.
|
|
199
|
+
This ensures matplotlib calls happen sequentially on the main thread.
|
|
200
|
+
"""
|
|
201
|
+
from ..visualizer.grids import visualize_land_cover_grid, visualize_numerical_grid
|
|
202
|
+
from ..utils.lc import get_land_cover_classes
|
|
203
|
+
|
|
204
|
+
# Visualize land cover (convert int grid back to string for visualization)
|
|
205
|
+
try:
|
|
206
|
+
land_cover_classes = get_land_cover_classes(land_cover_source)
|
|
207
|
+
# Create reverse mapping: int -> string class name
|
|
208
|
+
int_to_class = {i: name for i, name in enumerate(land_cover_classes.values())}
|
|
209
|
+
# Convert integer grid to string grid for visualization
|
|
210
|
+
land_cover_grid_str = np.empty(land_cover_grid.shape, dtype=object)
|
|
211
|
+
for i, name in int_to_class.items():
|
|
212
|
+
land_cover_grid_str[land_cover_grid == i] = name
|
|
213
|
+
color_map = {cls: [r/255, g/255, b/255] for (r,g,b), cls in land_cover_classes.items()}
|
|
214
|
+
visualize_land_cover_grid(np.flipud(land_cover_grid_str), meshsize, color_map, land_cover_classes)
|
|
215
|
+
except Exception as e:
|
|
216
|
+
get_logger(__name__).warning("Land cover visualization failed: %s", e)
|
|
217
|
+
|
|
218
|
+
# Visualize building height
|
|
219
|
+
try:
|
|
220
|
+
building_height_grid_nan = building_height_grid.copy().astype(float)
|
|
221
|
+
building_height_grid_nan[building_height_grid_nan == 0] = np.nan
|
|
222
|
+
visualize_numerical_grid(np.flipud(building_height_grid_nan), meshsize, "building height (m)", cmap='viridis', label='Value')
|
|
223
|
+
except Exception as e:
|
|
224
|
+
get_logger(__name__).warning("Building height visualization failed: %s", e)
|
|
225
|
+
|
|
226
|
+
# Visualize canopy height (if provided)
|
|
227
|
+
if canopy_top is not None:
|
|
228
|
+
try:
|
|
229
|
+
canopy_height_grid_nan = canopy_top.copy()
|
|
230
|
+
canopy_height_grid_nan[canopy_height_grid_nan == 0] = np.nan
|
|
231
|
+
visualize_numerical_grid(np.flipud(canopy_height_grid_nan), meshsize, "Tree canopy height", cmap='Greens', label='Tree canopy height (m)')
|
|
232
|
+
except Exception as e:
|
|
233
|
+
get_logger(__name__).warning("Canopy height visualization failed: %s", e)
|
|
234
|
+
|
|
235
|
+
# Visualize DEM
|
|
236
|
+
try:
|
|
237
|
+
visualize_numerical_grid(np.flipud(dem_grid), meshsize, title='Digital Elevation Model', cmap='terrain', label='Elevation (m)')
|
|
238
|
+
except Exception as e:
|
|
239
|
+
get_logger(__name__).warning("DEM visualization failed: %s", e)
|
|
240
|
+
|
|
241
|
+
def _run_parallel_downloads(
|
|
242
|
+
self, cfg, land_strategy, build_strategy, canopy_strategy, dem_strategy,
|
|
243
|
+
building_gdf, terrain_gdf, kwargs
|
|
244
|
+
):
|
|
245
|
+
"""
|
|
246
|
+
Run all 4 downloads (land_cover, building, canopy, dem) in parallel.
|
|
247
|
+
Used when canopy source is NOT 'Static' (no land_cover dependency).
|
|
248
|
+
"""
|
|
249
|
+
import logging
|
|
250
|
+
logger = get_logger(__name__)
|
|
251
|
+
|
|
252
|
+
# Print clean header for parallel mode
|
|
253
|
+
print("\n" + "="*60)
|
|
254
|
+
print("Downloading data in parallel mode (4 concurrent downloads)")
|
|
255
|
+
print("="*60)
|
|
256
|
+
print(f" • Land Cover: {cfg.land_cover_source}")
|
|
257
|
+
print(f" • Building: {cfg.building_source}")
|
|
258
|
+
print(f" • Canopy: {cfg.canopy_height_source}")
|
|
259
|
+
print(f" • DEM: {cfg.dem_source}")
|
|
260
|
+
print("-"*60)
|
|
261
|
+
print("Downloading... (this may take a moment)")
|
|
262
|
+
|
|
263
|
+
results = {}
|
|
264
|
+
|
|
265
|
+
# Disable gridvis and verbose prints in parallel mode
|
|
266
|
+
# Also suppress httpx INFO logs during parallel downloads
|
|
267
|
+
parallel_kwargs = {**kwargs, 'gridvis': False, 'print_class_info': False, 'quiet': True}
|
|
268
|
+
lc_opts = {**cfg.land_cover_options, 'gridvis': False, 'print_class_info': False, 'quiet': True}
|
|
269
|
+
bld_opts = {**cfg.building_options, 'gridvis': False, 'quiet': True}
|
|
270
|
+
canopy_opts = {**cfg.canopy_options, 'gridvis': False, 'quiet': True}
|
|
271
|
+
dem_opts = {**cfg.dem_options, 'gridvis': False, 'quiet': True}
|
|
272
|
+
|
|
273
|
+
# Suppress httpx verbose logging during parallel downloads
|
|
274
|
+
httpx_logger = logging.getLogger("httpx")
|
|
275
|
+
original_httpx_level = httpx_logger.level
|
|
276
|
+
httpx_logger.setLevel(logging.WARNING)
|
|
277
|
+
|
|
278
|
+
def download_land_cover():
|
|
279
|
+
grid = land_strategy.build_grid(
|
|
280
|
+
cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
|
|
281
|
+
**{**lc_opts, **parallel_kwargs}
|
|
282
|
+
)
|
|
283
|
+
# Get effective source after download
|
|
284
|
+
try:
|
|
285
|
+
from .grids import get_last_effective_land_cover_source
|
|
286
|
+
effective = get_last_effective_land_cover_source() or cfg.land_cover_source
|
|
287
|
+
except Exception:
|
|
288
|
+
effective = cfg.land_cover_source
|
|
289
|
+
return ('land_cover', (grid, effective))
|
|
290
|
+
|
|
291
|
+
def download_building():
|
|
292
|
+
bh, bmin, bid, gdf_out = build_strategy.build_grids(
|
|
293
|
+
cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
|
|
294
|
+
building_gdf=building_gdf,
|
|
295
|
+
**{**bld_opts, **parallel_kwargs}
|
|
296
|
+
)
|
|
297
|
+
return ('building', (bh, bmin, bid, gdf_out))
|
|
298
|
+
|
|
299
|
+
def download_canopy():
|
|
300
|
+
# For non-static canopy, we don't need land_cover_grid
|
|
301
|
+
# Pass None or empty array as placeholder - the strategy will download from GEE
|
|
302
|
+
placeholder_grid = np.zeros((1, 1), dtype=float)
|
|
303
|
+
top, bottom = canopy_strategy.build_grids(
|
|
304
|
+
cfg.rectangle_vertices, cfg.meshsize, placeholder_grid, cfg.output_dir,
|
|
305
|
+
land_cover_source=cfg.land_cover_source,
|
|
306
|
+
**{**canopy_opts, **parallel_kwargs}
|
|
307
|
+
)
|
|
308
|
+
return ('canopy', (top, bottom))
|
|
309
|
+
|
|
310
|
+
def download_dem():
|
|
311
|
+
# DEM no longer depends on land_cover_like for shape
|
|
312
|
+
dem = dem_strategy.build_grid(
|
|
313
|
+
cfg.rectangle_vertices, cfg.meshsize, None, cfg.output_dir,
|
|
314
|
+
terrain_gdf=terrain_gdf,
|
|
315
|
+
**{**dem_opts, **parallel_kwargs}
|
|
316
|
+
)
|
|
317
|
+
return ('dem', dem)
|
|
318
|
+
|
|
319
|
+
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
320
|
+
futures = [
|
|
321
|
+
executor.submit(download_land_cover),
|
|
322
|
+
executor.submit(download_building),
|
|
323
|
+
executor.submit(download_canopy),
|
|
324
|
+
executor.submit(download_dem),
|
|
325
|
+
]
|
|
326
|
+
completed_count = 0
|
|
327
|
+
for future in as_completed(futures):
|
|
328
|
+
try:
|
|
329
|
+
key, value = future.result()
|
|
330
|
+
results[key] = value
|
|
331
|
+
completed_count += 1
|
|
332
|
+
print(f" ✓ {key.replace('_', ' ').title()} complete ({completed_count}/4)")
|
|
333
|
+
except Exception as e:
|
|
334
|
+
logger.error("Parallel download failed: %s", e)
|
|
335
|
+
httpx_logger.setLevel(original_httpx_level) # Restore before raising
|
|
336
|
+
raise
|
|
337
|
+
|
|
338
|
+
# Restore httpx logging level
|
|
339
|
+
httpx_logger.setLevel(original_httpx_level)
|
|
340
|
+
|
|
341
|
+
print("-"*60)
|
|
342
|
+
print("All downloads complete!")
|
|
343
|
+
print("="*60 + "\n")
|
|
344
|
+
|
|
345
|
+
land_cover_grid, lc_src_effective = results['land_cover']
|
|
346
|
+
bh, bmin, bid, building_gdf_out = results['building']
|
|
347
|
+
canopy_top, canopy_bottom = results['canopy']
|
|
348
|
+
dem = results['dem']
|
|
349
|
+
|
|
350
|
+
return land_cover_grid, bh, bmin, bid, building_gdf_out, canopy_top, canopy_bottom, dem, lc_src_effective
|
|
351
|
+
|
|
352
|
+
def _run_parallel_downloads_static_canopy(
|
|
353
|
+
self, cfg, land_strategy, build_strategy, dem_strategy,
|
|
354
|
+
building_gdf, terrain_gdf, kwargs
|
|
355
|
+
):
|
|
356
|
+
"""
|
|
357
|
+
Run land_cover, building, and dem downloads in parallel.
|
|
358
|
+
Canopy (Static mode) will be run sequentially after, as it needs land_cover_grid.
|
|
359
|
+
"""
|
|
360
|
+
import logging
|
|
361
|
+
logger = get_logger(__name__)
|
|
362
|
+
|
|
363
|
+
# Print clean header for parallel mode
|
|
364
|
+
print("\n" + "="*60)
|
|
365
|
+
print("Downloading data in parallel mode (3 concurrent + 1 deferred)")
|
|
366
|
+
print("="*60)
|
|
367
|
+
print(f" • Land Cover: {cfg.land_cover_source}")
|
|
368
|
+
print(f" • Building: {cfg.building_source}")
|
|
369
|
+
print(f" • DEM: {cfg.dem_source}")
|
|
370
|
+
print(f" • Canopy: {cfg.canopy_height_source} (deferred)")
|
|
371
|
+
print("-"*60)
|
|
372
|
+
print("Downloading... (this may take a moment)")
|
|
373
|
+
|
|
374
|
+
results = {}
|
|
375
|
+
|
|
376
|
+
# Disable gridvis and verbose prints in parallel mode
|
|
377
|
+
parallel_kwargs = {**kwargs, 'gridvis': False, 'print_class_info': False, 'quiet': True}
|
|
378
|
+
lc_opts = {**cfg.land_cover_options, 'gridvis': False, 'print_class_info': False, 'quiet': True}
|
|
379
|
+
bld_opts = {**cfg.building_options, 'gridvis': False, 'quiet': True}
|
|
380
|
+
dem_opts = {**cfg.dem_options, 'gridvis': False, 'quiet': True}
|
|
381
|
+
|
|
382
|
+
# Suppress httpx verbose logging during parallel downloads
|
|
383
|
+
httpx_logger = logging.getLogger("httpx")
|
|
384
|
+
original_httpx_level = httpx_logger.level
|
|
385
|
+
httpx_logger.setLevel(logging.WARNING)
|
|
386
|
+
|
|
387
|
+
def download_land_cover():
|
|
388
|
+
grid = land_strategy.build_grid(
|
|
389
|
+
cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
|
|
390
|
+
**{**lc_opts, **parallel_kwargs}
|
|
391
|
+
)
|
|
392
|
+
try:
|
|
393
|
+
from .grids import get_last_effective_land_cover_source
|
|
394
|
+
effective = get_last_effective_land_cover_source() or cfg.land_cover_source
|
|
395
|
+
except Exception:
|
|
396
|
+
effective = cfg.land_cover_source
|
|
397
|
+
return ('land_cover', (grid, effective))
|
|
398
|
+
|
|
399
|
+
def download_building():
|
|
400
|
+
bh, bmin, bid, gdf_out = build_strategy.build_grids(
|
|
401
|
+
cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
|
|
402
|
+
building_gdf=building_gdf,
|
|
403
|
+
**{**bld_opts, **parallel_kwargs}
|
|
404
|
+
)
|
|
405
|
+
return ('building', (bh, bmin, bid, gdf_out))
|
|
406
|
+
|
|
407
|
+
def download_dem():
|
|
408
|
+
dem = dem_strategy.build_grid(
|
|
409
|
+
cfg.rectangle_vertices, cfg.meshsize, None, cfg.output_dir,
|
|
410
|
+
terrain_gdf=terrain_gdf,
|
|
411
|
+
**{**dem_opts, **parallel_kwargs}
|
|
412
|
+
)
|
|
413
|
+
return ('dem', dem)
|
|
414
|
+
|
|
415
|
+
with ThreadPoolExecutor(max_workers=3) as executor:
|
|
416
|
+
futures = [
|
|
417
|
+
executor.submit(download_land_cover),
|
|
418
|
+
executor.submit(download_building),
|
|
419
|
+
executor.submit(download_dem),
|
|
420
|
+
]
|
|
421
|
+
completed_count = 0
|
|
422
|
+
for future in as_completed(futures):
|
|
423
|
+
try:
|
|
424
|
+
key, value = future.result()
|
|
425
|
+
results[key] = value
|
|
426
|
+
completed_count += 1
|
|
427
|
+
print(f" ✓ {key.replace('_', ' ').title()} complete ({completed_count}/3)")
|
|
428
|
+
except Exception as e:
|
|
429
|
+
logger.error("Parallel download failed: %s", e)
|
|
430
|
+
httpx_logger.setLevel(original_httpx_level)
|
|
431
|
+
raise
|
|
432
|
+
|
|
433
|
+
# Restore httpx logging level
|
|
434
|
+
httpx_logger.setLevel(original_httpx_level)
|
|
435
|
+
|
|
436
|
+
print("-"*60)
|
|
437
|
+
print("Parallel downloads complete! Processing canopy...")
|
|
438
|
+
|
|
439
|
+
land_cover_grid, lc_src_effective = results['land_cover']
|
|
440
|
+
bh, bmin, bid, building_gdf_out = results['building']
|
|
441
|
+
dem = results['dem']
|
|
442
|
+
|
|
443
|
+
return land_cover_grid, bh, bmin, bid, building_gdf_out, dem, lc_src_effective
|
|
444
|
+
|
|
155
445
|
|
|
156
446
|
class LandCoverSourceStrategy: # ABC simplified to avoid dependency in split
|
|
157
447
|
def build_grid(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs): # pragma: no cover - interface
|
|
@@ -246,6 +536,11 @@ class DemSourceStrategy: # ABC simplified
|
|
|
246
536
|
|
|
247
537
|
class FlatDemStrategy(DemSourceStrategy):
|
|
248
538
|
def build_grid(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
|
|
539
|
+
# Compute shape from rectangle_vertices if land_cover_grid is None
|
|
540
|
+
if land_cover_grid is None:
|
|
541
|
+
from ..geoprocessor.raster.core import compute_grid_shape
|
|
542
|
+
grid_shape = compute_grid_shape(rectangle_vertices, meshsize)
|
|
543
|
+
return np.zeros(grid_shape, dtype=float)
|
|
249
544
|
return np.zeros_like(land_cover_grid)
|
|
250
545
|
|
|
251
546
|
|
|
@@ -264,6 +559,11 @@ class SourceDemStrategy(DemSourceStrategy):
|
|
|
264
559
|
# Fallback to flat DEM if source fails or unsupported
|
|
265
560
|
logger = get_logger(__name__)
|
|
266
561
|
logger.warning("DEM source '%s' failed (%s). Falling back to flat DEM.", self.source, e)
|
|
562
|
+
# Compute shape from rectangle_vertices if land_cover_grid is None
|
|
563
|
+
if land_cover_grid is None:
|
|
564
|
+
from ..geoprocessor.raster.core import compute_grid_shape
|
|
565
|
+
grid_shape = compute_grid_shape(rectangle_vertices, meshsize)
|
|
566
|
+
return np.zeros(grid_shape, dtype=float)
|
|
267
567
|
return np.zeros_like(land_cover_grid)
|
|
268
568
|
|
|
269
569
|
|
voxcity/geoprocessor/draw.py
CHANGED
|
@@ -337,23 +337,29 @@ def center_location_map_cityname(cityname, east_west_length, north_south_length,
|
|
|
337
337
|
print(f"Point drawn at Longitude: {lon}, Latitude: {lat}")
|
|
338
338
|
|
|
339
339
|
# Calculate corner points using geopy's distance calculator
|
|
340
|
-
#
|
|
340
|
+
# First calculate north/south latitudes from center
|
|
341
341
|
north = distance.distance(meters=north_south_length/2).destination((lat, lon), bearing=0)
|
|
342
342
|
south = distance.distance(meters=north_south_length/2).destination((lat, lon), bearing=180)
|
|
343
|
-
|
|
344
|
-
|
|
343
|
+
|
|
344
|
+
# Calculate east/west at the SOUTH latitude to ensure correct grid dimensions
|
|
345
|
+
# The grid size calculation uses the SW corner (vertex_0) as reference,
|
|
346
|
+
# measuring E-W distance along the south edge. By calculating east/west
|
|
347
|
+
# at the south latitude, we ensure the E-W distance matches the requested length.
|
|
348
|
+
east_at_south = distance.distance(meters=east_west_length/2).destination((south.latitude, lon), bearing=90)
|
|
349
|
+
west_at_south = distance.distance(meters=east_west_length/2).destination((south.latitude, lon), bearing=270)
|
|
345
350
|
|
|
346
351
|
# Create rectangle vertices in counter-clockwise order (lon,lat)
|
|
352
|
+
# Using the east/west longitudes calculated at south latitude for all corners
|
|
347
353
|
rectangle_vertices.extend([
|
|
348
|
-
(
|
|
349
|
-
(
|
|
350
|
-
(
|
|
351
|
-
(
|
|
354
|
+
(west_at_south.longitude, south.latitude),
|
|
355
|
+
(west_at_south.longitude, north.latitude),
|
|
356
|
+
(east_at_south.longitude, north.latitude),
|
|
357
|
+
(east_at_south.longitude, south.latitude)
|
|
352
358
|
])
|
|
353
359
|
|
|
354
360
|
# Create and add new rectangle to map (ipyleaflet expects lat,lon)
|
|
355
361
|
rectangle = Rectangle(
|
|
356
|
-
bounds=[(north.latitude,
|
|
362
|
+
bounds=[(north.latitude, west_at_south.longitude), (south.latitude, east_at_south.longitude)],
|
|
357
363
|
color="red",
|
|
358
364
|
fill_color="red",
|
|
359
365
|
fill_opacity=0.2
|
|
@@ -19,6 +19,7 @@ from .core import (
|
|
|
19
19
|
calculate_grid_size,
|
|
20
20
|
create_coordinate_mesh,
|
|
21
21
|
create_cell_polygon,
|
|
22
|
+
compute_grid_shape,
|
|
22
23
|
)
|
|
23
24
|
|
|
24
25
|
from .landcover import (
|
|
@@ -58,6 +59,7 @@ __all__ = [
|
|
|
58
59
|
"calculate_grid_size",
|
|
59
60
|
"create_coordinate_mesh",
|
|
60
61
|
"create_cell_polygon",
|
|
62
|
+
"compute_grid_shape",
|
|
61
63
|
# landcover
|
|
62
64
|
"tree_height_grid_from_land_cover",
|
|
63
65
|
"create_land_cover_grid_from_geotiff_polygon",
|
|
@@ -2,6 +2,8 @@ import numpy as np
|
|
|
2
2
|
from typing import Tuple, Dict, Any
|
|
3
3
|
from shapely.geometry import Polygon
|
|
4
4
|
|
|
5
|
+
from ..utils import initialize_geod, calculate_distance, normalize_to_one_meter
|
|
6
|
+
|
|
5
7
|
|
|
6
8
|
def apply_operation(arr: np.ndarray, meshsize: float) -> np.ndarray:
|
|
7
9
|
"""
|
|
@@ -146,5 +148,34 @@ def create_cell_polygon(
|
|
|
146
148
|
return Polygon([bottom_left, bottom_right, top_right, top_left])
|
|
147
149
|
|
|
148
150
|
|
|
151
|
+
def compute_grid_shape(rectangle_vertices, meshsize: float) -> Tuple[int, int]:
|
|
152
|
+
"""
|
|
153
|
+
Compute the grid dimensions (rows, cols) for a given rectangle and mesh size.
|
|
154
|
+
|
|
155
|
+
This is useful when you need to know the output grid shape without
|
|
156
|
+
actually creating the grid (e.g., for pre-allocating arrays or fallback shapes).
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
rectangle_vertices: List of 4 vertices [(lon, lat), ...] defining the rectangle.
|
|
160
|
+
meshsize: Grid cell size in meters.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Tuple of (grid_size_0, grid_size_1) representing grid dimensions.
|
|
164
|
+
"""
|
|
165
|
+
geod = initialize_geod()
|
|
166
|
+
vertex_0, vertex_1, vertex_3 = rectangle_vertices[0], rectangle_vertices[1], rectangle_vertices[3]
|
|
167
|
+
|
|
168
|
+
dist_side_1 = calculate_distance(geod, vertex_0[0], vertex_0[1], vertex_1[0], vertex_1[1])
|
|
169
|
+
dist_side_2 = calculate_distance(geod, vertex_0[0], vertex_0[1], vertex_3[0], vertex_3[1])
|
|
170
|
+
|
|
171
|
+
side_1 = np.array(vertex_1) - np.array(vertex_0)
|
|
172
|
+
side_2 = np.array(vertex_3) - np.array(vertex_0)
|
|
173
|
+
u_vec = normalize_to_one_meter(side_1, dist_side_1)
|
|
174
|
+
v_vec = normalize_to_one_meter(side_2, dist_side_2)
|
|
175
|
+
|
|
176
|
+
grid_size, _ = calculate_grid_size(side_1, side_2, u_vec, v_vec, meshsize)
|
|
177
|
+
return grid_size
|
|
178
|
+
|
|
179
|
+
|
|
149
180
|
|
|
150
181
|
|