voxcity 0.6.15__py3-none-any.whl → 0.7.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voxcity/__init__.py +14 -8
- voxcity/downloader/__init__.py +2 -1
- voxcity/downloader/citygml.py +32 -18
- voxcity/downloader/gba.py +210 -0
- voxcity/downloader/gee.py +5 -1
- voxcity/downloader/mbfp.py +1 -1
- voxcity/downloader/oemj.py +80 -8
- voxcity/downloader/osm.py +23 -7
- voxcity/downloader/overture.py +26 -1
- voxcity/downloader/utils.py +73 -73
- voxcity/errors.py +30 -0
- voxcity/exporter/__init__.py +13 -4
- voxcity/exporter/cityles.py +633 -535
- voxcity/exporter/envimet.py +728 -708
- voxcity/exporter/magicavoxel.py +334 -297
- voxcity/exporter/netcdf.py +238 -0
- voxcity/exporter/obj.py +1481 -655
- voxcity/generator/__init__.py +44 -0
- voxcity/generator/api.py +675 -0
- voxcity/generator/grids.py +379 -0
- voxcity/generator/io.py +94 -0
- voxcity/generator/pipeline.py +282 -0
- voxcity/generator/voxelizer.py +380 -0
- voxcity/geoprocessor/__init__.py +75 -6
- voxcity/geoprocessor/conversion.py +153 -0
- voxcity/geoprocessor/draw.py +62 -12
- voxcity/geoprocessor/heights.py +199 -0
- voxcity/geoprocessor/io.py +101 -0
- voxcity/geoprocessor/merge_utils.py +91 -0
- voxcity/geoprocessor/mesh.py +806 -790
- voxcity/geoprocessor/network.py +708 -679
- voxcity/geoprocessor/overlap.py +84 -0
- voxcity/geoprocessor/raster/__init__.py +82 -0
- voxcity/geoprocessor/raster/buildings.py +428 -0
- voxcity/geoprocessor/raster/canopy.py +258 -0
- voxcity/geoprocessor/raster/core.py +150 -0
- voxcity/geoprocessor/raster/export.py +93 -0
- voxcity/geoprocessor/raster/landcover.py +156 -0
- voxcity/geoprocessor/raster/raster.py +110 -0
- voxcity/geoprocessor/selection.py +85 -0
- voxcity/geoprocessor/utils.py +18 -14
- voxcity/models.py +113 -0
- voxcity/simulator/common/__init__.py +22 -0
- voxcity/simulator/common/geometry.py +98 -0
- voxcity/simulator/common/raytracing.py +450 -0
- voxcity/simulator/solar/__init__.py +43 -0
- voxcity/simulator/solar/integration.py +336 -0
- voxcity/simulator/solar/kernels.py +62 -0
- voxcity/simulator/solar/radiation.py +648 -0
- voxcity/simulator/solar/temporal.py +434 -0
- voxcity/simulator/view.py +36 -2286
- voxcity/simulator/visibility/__init__.py +29 -0
- voxcity/simulator/visibility/landmark.py +392 -0
- voxcity/simulator/visibility/view.py +508 -0
- voxcity/utils/logging.py +61 -0
- voxcity/utils/orientation.py +51 -0
- voxcity/utils/weather/__init__.py +26 -0
- voxcity/utils/weather/epw.py +146 -0
- voxcity/utils/weather/files.py +36 -0
- voxcity/utils/weather/onebuilding.py +486 -0
- voxcity/visualizer/__init__.py +24 -0
- voxcity/visualizer/builder.py +43 -0
- voxcity/visualizer/grids.py +141 -0
- voxcity/visualizer/maps.py +187 -0
- voxcity/visualizer/palette.py +228 -0
- voxcity/visualizer/renderer.py +928 -0
- {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/METADATA +113 -36
- voxcity-0.7.0.dist-info/RECORD +77 -0
- {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info}/WHEEL +1 -1
- voxcity/generator.py +0 -1137
- voxcity/geoprocessor/grid.py +0 -1568
- voxcity/geoprocessor/polygon.py +0 -1344
- voxcity/simulator/solar.py +0 -2329
- voxcity/utils/visualization.py +0 -2660
- voxcity/utils/weather.py +0 -817
- voxcity-0.6.15.dist-info/RECORD +0 -37
- {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/AUTHORS.rst +0 -0
- {voxcity-0.6.15.dist-info → voxcity-0.7.0.dist-info/licenses}/LICENSE +0 -0
voxcity/geoprocessor/__init__.py
CHANGED
|
@@ -1,6 +1,75 @@
|
|
|
1
|
-
from .
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
from . import (
|
|
2
|
+
draw,
|
|
3
|
+
utils,
|
|
4
|
+
network,
|
|
5
|
+
mesh,
|
|
6
|
+
raster,
|
|
7
|
+
conversion,
|
|
8
|
+
io,
|
|
9
|
+
heights,
|
|
10
|
+
selection,
|
|
11
|
+
overlap,
|
|
12
|
+
merge_utils,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
# Re-export frequently used functions at package level for convenience
|
|
16
|
+
from .conversion import (
|
|
17
|
+
filter_and_convert_gdf_to_geojson,
|
|
18
|
+
geojson_to_gdf,
|
|
19
|
+
gdf_to_geojson_dicts,
|
|
20
|
+
)
|
|
21
|
+
from .io import (
|
|
22
|
+
get_geojson_from_gpkg,
|
|
23
|
+
get_gdf_from_gpkg,
|
|
24
|
+
load_gdf_from_multiple_gz,
|
|
25
|
+
swap_coordinates,
|
|
26
|
+
save_geojson,
|
|
27
|
+
)
|
|
28
|
+
from .heights import (
|
|
29
|
+
extract_building_heights_from_gdf,
|
|
30
|
+
extract_building_heights_from_geotiff,
|
|
31
|
+
complement_building_heights_from_gdf,
|
|
32
|
+
)
|
|
33
|
+
from .selection import (
|
|
34
|
+
filter_buildings,
|
|
35
|
+
find_building_containing_point,
|
|
36
|
+
get_buildings_in_drawn_polygon,
|
|
37
|
+
)
|
|
38
|
+
from .overlap import (
|
|
39
|
+
process_building_footprints_by_overlap,
|
|
40
|
+
)
|
|
41
|
+
from .merge_utils import (
|
|
42
|
+
merge_gdfs_with_id_conflict_resolution,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
# submodules
|
|
47
|
+
"draw",
|
|
48
|
+
"utils",
|
|
49
|
+
"network",
|
|
50
|
+
"mesh",
|
|
51
|
+
"raster",
|
|
52
|
+
"conversion",
|
|
53
|
+
"io",
|
|
54
|
+
"heights",
|
|
55
|
+
"selection",
|
|
56
|
+
"overlap",
|
|
57
|
+
"merge_utils",
|
|
58
|
+
# functions
|
|
59
|
+
"filter_and_convert_gdf_to_geojson",
|
|
60
|
+
"geojson_to_gdf",
|
|
61
|
+
"gdf_to_geojson_dicts",
|
|
62
|
+
"get_geojson_from_gpkg",
|
|
63
|
+
"get_gdf_from_gpkg",
|
|
64
|
+
"load_gdf_from_multiple_gz",
|
|
65
|
+
"swap_coordinates",
|
|
66
|
+
"save_geojson",
|
|
67
|
+
"extract_building_heights_from_gdf",
|
|
68
|
+
"extract_building_heights_from_geotiff",
|
|
69
|
+
"complement_building_heights_from_gdf",
|
|
70
|
+
"filter_buildings",
|
|
71
|
+
"find_building_containing_point",
|
|
72
|
+
"get_buildings_in_drawn_polygon",
|
|
73
|
+
"process_building_footprints_by_overlap",
|
|
74
|
+
"merge_gdfs_with_id_conflict_resolution",
|
|
75
|
+
]
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Conversion utilities between GeoJSON-like features and GeoPandas GeoDataFrames,
|
|
3
|
+
plus helpers to filter and transform geometries for export.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
from typing import List, Dict
|
|
8
|
+
|
|
9
|
+
import geopandas as gpd
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from shapely.geometry import Polygon, shape
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def filter_and_convert_gdf_to_geojson(gdf, rectangle_vertices):
|
|
15
|
+
"""
|
|
16
|
+
Filter a GeoDataFrame by a bounding rectangle and convert to GeoJSON format.
|
|
17
|
+
|
|
18
|
+
This function performs spatial filtering on a GeoDataFrame using a bounding rectangle,
|
|
19
|
+
and converts the filtered data to GeoJSON format. It handles both Polygon and MultiPolygon
|
|
20
|
+
geometries, splitting MultiPolygons into separate Polygon features.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
gdf (GeoDataFrame): Input GeoDataFrame containing building data
|
|
24
|
+
Must have 'geometry' and 'height' columns
|
|
25
|
+
Any CRS is accepted, will be converted to WGS84 if needed
|
|
26
|
+
rectangle_vertices (list): List of (lon, lat) tuples defining the bounding rectangle
|
|
27
|
+
Must be in WGS84 (EPSG:4326) coordinate system
|
|
28
|
+
Must form a valid rectangle (4 vertices, clockwise or counterclockwise)
|
|
29
|
+
|
|
30
|
+
Returns:
|
|
31
|
+
list: List of GeoJSON features within the bounding rectangle
|
|
32
|
+
Each feature contains:
|
|
33
|
+
- geometry: Polygon coordinates in WGS84
|
|
34
|
+
- properties: Dictionary with 'height', 'confidence', and 'id'
|
|
35
|
+
- type: Always "Feature"
|
|
36
|
+
|
|
37
|
+
Memory Optimization:
|
|
38
|
+
- Uses spatial indexing for efficient filtering
|
|
39
|
+
- Downcasts numeric columns to save memory
|
|
40
|
+
- Cleans up intermediate data structures
|
|
41
|
+
- Splits MultiPolygons into separate features
|
|
42
|
+
"""
|
|
43
|
+
if gdf.crs != 'EPSG:4326':
|
|
44
|
+
gdf = gdf.to_crs(epsg=4326)
|
|
45
|
+
|
|
46
|
+
gdf['height'] = pd.to_numeric(gdf['height'], downcast='float')
|
|
47
|
+
gdf['confidence'] = -1.0
|
|
48
|
+
|
|
49
|
+
rectangle_polygon = Polygon(rectangle_vertices)
|
|
50
|
+
|
|
51
|
+
gdf.sindex
|
|
52
|
+
possible_matches_index = list(gdf.sindex.intersection(rectangle_polygon.bounds))
|
|
53
|
+
possible_matches = gdf.iloc[possible_matches_index]
|
|
54
|
+
precise_matches = possible_matches[possible_matches.intersects(rectangle_polygon)]
|
|
55
|
+
filtered_gdf = precise_matches.copy()
|
|
56
|
+
|
|
57
|
+
del gdf, possible_matches, precise_matches
|
|
58
|
+
|
|
59
|
+
features = []
|
|
60
|
+
feature_id = 1
|
|
61
|
+
for _, row in filtered_gdf.iterrows():
|
|
62
|
+
geom = row['geometry'].__geo_interface__
|
|
63
|
+
properties = {
|
|
64
|
+
'height': row['height'],
|
|
65
|
+
'confidence': row['confidence'],
|
|
66
|
+
'id': feature_id
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if geom['type'] == 'MultiPolygon':
|
|
70
|
+
for polygon_coords in geom['coordinates']:
|
|
71
|
+
single_geom = {
|
|
72
|
+
'type': 'Polygon',
|
|
73
|
+
'coordinates': polygon_coords
|
|
74
|
+
}
|
|
75
|
+
feature = {
|
|
76
|
+
'type': 'Feature',
|
|
77
|
+
'properties': properties.copy(),
|
|
78
|
+
'geometry': single_geom
|
|
79
|
+
}
|
|
80
|
+
features.append(feature)
|
|
81
|
+
feature_id += 1
|
|
82
|
+
elif geom['type'] == 'Polygon':
|
|
83
|
+
feature = {
|
|
84
|
+
'type': 'Feature',
|
|
85
|
+
'properties': properties,
|
|
86
|
+
'geometry': geom
|
|
87
|
+
}
|
|
88
|
+
features.append(feature)
|
|
89
|
+
feature_id += 1
|
|
90
|
+
else:
|
|
91
|
+
pass
|
|
92
|
+
|
|
93
|
+
geojson = {
|
|
94
|
+
'type': 'FeatureCollection',
|
|
95
|
+
'features': features
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
del filtered_gdf, features
|
|
99
|
+
|
|
100
|
+
return geojson["features"]
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def geojson_to_gdf(geojson_data, id_col='id'):
|
|
104
|
+
"""
|
|
105
|
+
Convert a list of GeoJSON-like dict features into a GeoDataFrame.
|
|
106
|
+
|
|
107
|
+
This function takes a list of GeoJSON feature dictionaries (Fiona-like format)
|
|
108
|
+
and converts them into a GeoDataFrame, handling geometry conversion and property
|
|
109
|
+
extraction. It ensures each feature has a unique identifier.
|
|
110
|
+
"""
|
|
111
|
+
geometries = []
|
|
112
|
+
all_props = []
|
|
113
|
+
|
|
114
|
+
for i, feature in enumerate(geojson_data):
|
|
115
|
+
geom = feature.get('geometry')
|
|
116
|
+
shapely_geom = shape(geom) if geom else None
|
|
117
|
+
|
|
118
|
+
props = feature.get('properties', {})
|
|
119
|
+
if id_col not in props:
|
|
120
|
+
props[id_col] = i
|
|
121
|
+
|
|
122
|
+
geometries.append(shapely_geom)
|
|
123
|
+
all_props.append(props)
|
|
124
|
+
|
|
125
|
+
gdf = gpd.GeoDataFrame(all_props, geometry=geometries, crs="EPSG:4326")
|
|
126
|
+
return gdf
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def gdf_to_geojson_dicts(gdf, id_col='id'):
|
|
130
|
+
"""
|
|
131
|
+
Convert a GeoDataFrame to a list of dicts similar to GeoJSON features.
|
|
132
|
+
"""
|
|
133
|
+
records = gdf.to_dict(orient='records')
|
|
134
|
+
features = []
|
|
135
|
+
|
|
136
|
+
for rec in records:
|
|
137
|
+
geom = rec.pop('geometry', None)
|
|
138
|
+
if geom is not None:
|
|
139
|
+
geom = geom.__geo_interface__
|
|
140
|
+
|
|
141
|
+
_ = rec.get(id_col, None)
|
|
142
|
+
props = {k: v for k, v in rec.items() if k != id_col}
|
|
143
|
+
|
|
144
|
+
feature = {
|
|
145
|
+
'type': 'Feature',
|
|
146
|
+
'properties': props,
|
|
147
|
+
'geometry': geom
|
|
148
|
+
}
|
|
149
|
+
features.append(feature)
|
|
150
|
+
|
|
151
|
+
return features
|
|
152
|
+
|
|
153
|
+
|
voxcity/geoprocessor/draw.py
CHANGED
|
@@ -24,7 +24,7 @@ Dependencies:
|
|
|
24
24
|
"""
|
|
25
25
|
|
|
26
26
|
import math
|
|
27
|
-
from pyproj import
|
|
27
|
+
from pyproj import Transformer
|
|
28
28
|
from ipyleaflet import (
|
|
29
29
|
Map,
|
|
30
30
|
DrawControl,
|
|
@@ -45,6 +45,14 @@ from IPython.display import display, clear_output
|
|
|
45
45
|
|
|
46
46
|
from .utils import get_coordinates_from_cityname
|
|
47
47
|
|
|
48
|
+
# Import VoxCity for type checking (avoid circular import with TYPE_CHECKING)
|
|
49
|
+
try:
|
|
50
|
+
from typing import TYPE_CHECKING
|
|
51
|
+
if TYPE_CHECKING:
|
|
52
|
+
from ..models import VoxCity
|
|
53
|
+
except ImportError:
|
|
54
|
+
pass
|
|
55
|
+
|
|
48
56
|
def rotate_rectangle(m, rectangle_vertices, angle):
|
|
49
57
|
"""
|
|
50
58
|
Project rectangle to Mercator, rotate, and re-project to lat-lon coordinates.
|
|
@@ -82,12 +90,12 @@ def rotate_rectangle(m, rectangle_vertices, angle):
|
|
|
82
90
|
print("Draw a rectangle first!")
|
|
83
91
|
return
|
|
84
92
|
|
|
85
|
-
# Define
|
|
86
|
-
|
|
87
|
-
|
|
93
|
+
# Define transformers (modern pyproj API)
|
|
94
|
+
to_merc = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
|
|
95
|
+
to_wgs84 = Transformer.from_crs("EPSG:3857", "EPSG:4326", always_xy=True)
|
|
88
96
|
|
|
89
97
|
# Project vertices from WGS84 to Web Mercator for proper distance calculations
|
|
90
|
-
projected_vertices = [transform(
|
|
98
|
+
projected_vertices = [to_merc.transform(lon, lat) for lon, lat in rectangle_vertices]
|
|
91
99
|
|
|
92
100
|
# Calculate the centroid to use as rotation center
|
|
93
101
|
centroid_x = sum(x for x, y in projected_vertices) / len(projected_vertices)
|
|
@@ -114,7 +122,7 @@ def rotate_rectangle(m, rectangle_vertices, angle):
|
|
|
114
122
|
rotated_vertices.append((new_x, new_y))
|
|
115
123
|
|
|
116
124
|
# Convert coordinates back to WGS84 (lon/lat)
|
|
117
|
-
new_vertices = [transform(
|
|
125
|
+
new_vertices = [to_wgs84.transform(x, y) for x, y in rotated_vertices]
|
|
118
126
|
|
|
119
127
|
# Create and add new polygon layer to map
|
|
120
128
|
polygon = LeafletPolygon(
|
|
@@ -358,7 +366,7 @@ def center_location_map_cityname(cityname, east_west_length, north_south_length,
|
|
|
358
366
|
|
|
359
367
|
return m, rectangle_vertices
|
|
360
368
|
|
|
361
|
-
def display_buildings_and_draw_polygon(building_gdf=None, rectangle_vertices=None, zoom=17):
|
|
369
|
+
def display_buildings_and_draw_polygon(city=None, building_gdf=None, rectangle_vertices=None, zoom=17):
|
|
362
370
|
"""
|
|
363
371
|
Displays building footprints and enables polygon drawing on an interactive map.
|
|
364
372
|
|
|
@@ -384,13 +392,18 @@ def display_buildings_and_draw_polygon(building_gdf=None, rectangle_vertices=Non
|
|
|
384
392
|
- Support for both building data and rectangle bounds
|
|
385
393
|
|
|
386
394
|
Args:
|
|
395
|
+
city (VoxCity, optional): A VoxCity object from which to extract building_gdf
|
|
396
|
+
and rectangle_vertices. If provided, these values will be used unless
|
|
397
|
+
explicitly overridden by the building_gdf or rectangle_vertices parameters.
|
|
387
398
|
building_gdf (GeoDataFrame, optional): A GeoDataFrame containing building footprints.
|
|
388
399
|
Must have geometry column with Polygon type features.
|
|
389
400
|
Geometries should be in [lon, lat] coordinate order.
|
|
390
|
-
If None
|
|
401
|
+
If None and city is provided, uses city.extras['building_gdf'].
|
|
402
|
+
If None and no city provided, only the base map is displayed.
|
|
391
403
|
rectangle_vertices (list, optional): List of [lon, lat] coordinates defining rectangle corners.
|
|
392
404
|
Used to set the initial map view extent.
|
|
393
405
|
Takes precedence over building_gdf for determining map center.
|
|
406
|
+
If None and city is provided, uses city.extras['rectangle_vertices'].
|
|
394
407
|
zoom (int): Initial zoom level for the map. Default=17.
|
|
395
408
|
Range: 0 (most zoomed out) to 18 (most zoomed in).
|
|
396
409
|
Default of 17 is optimized for building-level detail.
|
|
@@ -401,6 +414,16 @@ def display_buildings_and_draw_polygon(building_gdf=None, rectangle_vertices=Non
|
|
|
401
414
|
- drawn_polygons: List of dictionaries with 'id', 'vertices', and 'color' keys for all drawn polygons.
|
|
402
415
|
Each polygon has a unique ID and color for easy identification.
|
|
403
416
|
|
|
417
|
+
Examples:
|
|
418
|
+
Using a VoxCity object:
|
|
419
|
+
>>> m, polygons = display_buildings_and_draw_polygon(city=my_city)
|
|
420
|
+
|
|
421
|
+
Using explicit parameters:
|
|
422
|
+
>>> m, polygons = display_buildings_and_draw_polygon(building_gdf=buildings, rectangle_vertices=rect)
|
|
423
|
+
|
|
424
|
+
Override specific parameters from VoxCity:
|
|
425
|
+
>>> m, polygons = display_buildings_and_draw_polygon(city=my_city, zoom=15)
|
|
426
|
+
|
|
404
427
|
Note:
|
|
405
428
|
- Building footprints are displayed in blue with 20% opacity
|
|
406
429
|
- Only simple Polygon geometries are supported (no MultiPolygons)
|
|
@@ -410,6 +433,18 @@ def display_buildings_and_draw_polygon(building_gdf=None, rectangle_vertices=Non
|
|
|
410
433
|
- Each polygon gets a unique ID and different colors for easy identification
|
|
411
434
|
- Use get_polygon_vertices() helper function to extract specific polygon data
|
|
412
435
|
"""
|
|
436
|
+
# ---------------------------------------------------------
|
|
437
|
+
# 0. Extract data from VoxCity object if provided
|
|
438
|
+
# ---------------------------------------------------------
|
|
439
|
+
if city is not None:
|
|
440
|
+
# Extract building_gdf if not explicitly provided
|
|
441
|
+
if building_gdf is None:
|
|
442
|
+
building_gdf = city.extras.get('building_gdf', None)
|
|
443
|
+
|
|
444
|
+
# Extract rectangle_vertices if not explicitly provided
|
|
445
|
+
if rectangle_vertices is None:
|
|
446
|
+
rectangle_vertices = city.extras.get('rectangle_vertices', None)
|
|
447
|
+
|
|
413
448
|
# ---------------------------------------------------------
|
|
414
449
|
# 1. Determine a suitable map center via bounding box logic
|
|
415
450
|
# ---------------------------------------------------------
|
|
@@ -514,7 +549,7 @@ def display_buildings_and_draw_polygon(building_gdf=None, rectangle_vertices=Non
|
|
|
514
549
|
|
|
515
550
|
return m, drawn_polygons
|
|
516
551
|
|
|
517
|
-
def draw_additional_buildings(building_gdf=None, initial_center=None, zoom=17, rectangle_vertices=None):
|
|
552
|
+
def draw_additional_buildings(city=None, building_gdf=None, initial_center=None, zoom=17, rectangle_vertices=None):
|
|
518
553
|
"""
|
|
519
554
|
Creates an interactive map for drawing building footprints with height input.
|
|
520
555
|
|
|
@@ -530,8 +565,12 @@ def draw_additional_buildings(building_gdf=None, initial_center=None, zoom=17, r
|
|
|
530
565
|
- Building is added to GeoDataFrame and displayed on map
|
|
531
566
|
|
|
532
567
|
Args:
|
|
568
|
+
city (VoxCity, optional): A VoxCity object from which to extract building_gdf
|
|
569
|
+
and rectangle_vertices. If provided, these values will be used unless
|
|
570
|
+
explicitly overridden by the other parameters.
|
|
533
571
|
building_gdf (GeoDataFrame, optional): Existing building footprints to display.
|
|
534
|
-
If None
|
|
572
|
+
If None and city is provided, uses city.extras['building_gdf'].
|
|
573
|
+
If None and no city provided, creates a new empty GeoDataFrame.
|
|
535
574
|
Expected columns: ['id', 'height', 'min_height', 'geometry', 'building_id']
|
|
536
575
|
- 'id': Integer ID from data sources (e.g., OSM building id)
|
|
537
576
|
- 'height': Building height in meters (set by user input)
|
|
@@ -541,18 +580,29 @@ def draw_additional_buildings(building_gdf=None, initial_center=None, zoom=17, r
|
|
|
541
580
|
initial_center (tuple, optional): Initial map center as (lon, lat).
|
|
542
581
|
If None, centers on existing buildings or defaults to (-100, 40).
|
|
543
582
|
zoom (int): Initial zoom level (default=17).
|
|
583
|
+
rectangle_vertices (list, optional): List of [lon, lat] coordinates defining rectangle corners.
|
|
584
|
+
If None and city is provided, uses city.extras['rectangle_vertices'].
|
|
544
585
|
|
|
545
586
|
Returns:
|
|
546
587
|
tuple: (map_object, updated_building_gdf)
|
|
547
588
|
- map_object: ipyleaflet Map instance with drawing controls
|
|
548
589
|
- updated_building_gdf: GeoDataFrame that automatically updates when buildings are added
|
|
549
590
|
|
|
550
|
-
|
|
551
|
-
|
|
591
|
+
Examples:
|
|
592
|
+
Using a VoxCity object:
|
|
593
|
+
>>> m, buildings = draw_additional_buildings(city=my_city)
|
|
594
|
+
|
|
595
|
+
Start with empty buildings:
|
|
552
596
|
>>> m, buildings = draw_additional_buildings()
|
|
553
597
|
>>> # Draw buildings on the map...
|
|
554
598
|
>>> print(buildings) # Will contain all drawn buildings
|
|
555
599
|
"""
|
|
600
|
+
# Extract data from VoxCity object if provided
|
|
601
|
+
if city is not None:
|
|
602
|
+
if building_gdf is None:
|
|
603
|
+
building_gdf = city.extras.get('building_gdf', None)
|
|
604
|
+
if rectangle_vertices is None:
|
|
605
|
+
rectangle_vertices = city.extras.get('rectangle_vertices', None)
|
|
556
606
|
|
|
557
607
|
# Initialize or copy the building GeoDataFrame
|
|
558
608
|
if building_gdf is None:
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Height extraction and complement utilities for building footprints.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from typing import List, Dict
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
import geopandas as gpd
|
|
9
|
+
import pandas as pd
|
|
10
|
+
from shapely.errors import GEOSException
|
|
11
|
+
from shapely.geometry import shape
|
|
12
|
+
from rtree import index
|
|
13
|
+
import rasterio
|
|
14
|
+
from pyproj import Transformer, CRS
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def extract_building_heights_from_gdf(gdf_0: gpd.GeoDataFrame, gdf_1: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
|
|
18
|
+
"""
|
|
19
|
+
Extract building heights from one GeoDataFrame and apply them to another based on spatial overlap.
|
|
20
|
+
"""
|
|
21
|
+
gdf_primary = gdf_0.copy()
|
|
22
|
+
gdf_ref = gdf_1.copy()
|
|
23
|
+
|
|
24
|
+
if 'height' not in gdf_primary.columns:
|
|
25
|
+
gdf_primary['height'] = 0.0
|
|
26
|
+
if 'height' not in gdf_ref.columns:
|
|
27
|
+
gdf_ref['height'] = 0.0
|
|
28
|
+
|
|
29
|
+
count_0 = 0
|
|
30
|
+
count_1 = 0
|
|
31
|
+
count_2 = 0
|
|
32
|
+
|
|
33
|
+
spatial_index = index.Index()
|
|
34
|
+
for i, geom in enumerate(gdf_ref.geometry):
|
|
35
|
+
if geom.is_valid:
|
|
36
|
+
spatial_index.insert(i, geom.bounds)
|
|
37
|
+
|
|
38
|
+
for idx_primary, row in gdf_primary.iterrows():
|
|
39
|
+
if row['height'] <= 0 or pd.isna(row['height']):
|
|
40
|
+
count_0 += 1
|
|
41
|
+
geom = row.geometry
|
|
42
|
+
|
|
43
|
+
overlapping_height_area = 0
|
|
44
|
+
overlapping_area = 0
|
|
45
|
+
|
|
46
|
+
potential_matches = list(spatial_index.intersection(geom.bounds))
|
|
47
|
+
|
|
48
|
+
for ref_idx in potential_matches:
|
|
49
|
+
if ref_idx >= len(gdf_ref):
|
|
50
|
+
continue
|
|
51
|
+
|
|
52
|
+
ref_row = gdf_ref.iloc[ref_idx]
|
|
53
|
+
try:
|
|
54
|
+
if geom.intersects(ref_row.geometry):
|
|
55
|
+
overlap_area = geom.intersection(ref_row.geometry).area
|
|
56
|
+
overlapping_height_area += ref_row['height'] * overlap_area
|
|
57
|
+
overlapping_area += overlap_area
|
|
58
|
+
except GEOSException:
|
|
59
|
+
try:
|
|
60
|
+
fixed_ref_geom = ref_row.geometry.buffer(0)
|
|
61
|
+
if geom.intersects(fixed_ref_geom):
|
|
62
|
+
overlap_area = geom.intersection(fixed_ref_geom).area
|
|
63
|
+
overlapping_height_area += ref_row['height'] * overlap_area
|
|
64
|
+
overlapping_area += overlap_area
|
|
65
|
+
except Exception:
|
|
66
|
+
print(f"Failed to fix polygon")
|
|
67
|
+
continue
|
|
68
|
+
|
|
69
|
+
if overlapping_height_area > 0:
|
|
70
|
+
count_1 += 1
|
|
71
|
+
new_height = overlapping_height_area / overlapping_area
|
|
72
|
+
gdf_primary.at[idx_primary, 'height'] = new_height
|
|
73
|
+
else:
|
|
74
|
+
count_2 += 1
|
|
75
|
+
gdf_primary.at[idx_primary, 'height'] = np.nan
|
|
76
|
+
|
|
77
|
+
if count_0 > 0:
|
|
78
|
+
print(f"For {count_1} of these building footprints without height, values from the complementary source were assigned.")
|
|
79
|
+
print(f"For {count_2} of these building footprints without height, no data exist in complementary data.")
|
|
80
|
+
|
|
81
|
+
return gdf_primary
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def complement_building_heights_from_gdf(gdf_0, gdf_1, primary_id='id', ref_id='id'):
|
|
85
|
+
"""
|
|
86
|
+
Vectorized approach with GeoPandas to compute weighted heights and add non-intersecting buildings.
|
|
87
|
+
Returns a single combined GeoDataFrame.
|
|
88
|
+
"""
|
|
89
|
+
gdf_primary = gdf_0.copy()
|
|
90
|
+
gdf_ref = gdf_1.copy()
|
|
91
|
+
|
|
92
|
+
if 'height' not in gdf_primary.columns:
|
|
93
|
+
gdf_primary['height'] = 0.0
|
|
94
|
+
if 'height' not in gdf_ref.columns:
|
|
95
|
+
gdf_ref['height'] = 0.0
|
|
96
|
+
|
|
97
|
+
gdf_primary = gdf_primary.rename(columns={'height': 'height_primary'})
|
|
98
|
+
gdf_ref = gdf_ref.rename(columns={'height': 'height_ref'})
|
|
99
|
+
|
|
100
|
+
intersect_gdf = gpd.overlay(gdf_primary, gdf_ref, how='intersection')
|
|
101
|
+
intersect_gdf['intersect_area'] = intersect_gdf.area
|
|
102
|
+
intersect_gdf['height_area'] = intersect_gdf['height_ref'] * intersect_gdf['intersect_area']
|
|
103
|
+
|
|
104
|
+
group_cols = {
|
|
105
|
+
'height_area': 'sum',
|
|
106
|
+
'intersect_area': 'sum'
|
|
107
|
+
}
|
|
108
|
+
grouped = intersect_gdf.groupby(f'{primary_id}_1').agg(group_cols)
|
|
109
|
+
grouped['weighted_height'] = grouped['height_area'] / grouped['intersect_area']
|
|
110
|
+
|
|
111
|
+
gdf_primary = gdf_primary.merge(grouped['weighted_height'],
|
|
112
|
+
left_on=primary_id,
|
|
113
|
+
right_index=True,
|
|
114
|
+
how='left')
|
|
115
|
+
|
|
116
|
+
zero_or_nan_mask = (gdf_primary['height_primary'] == 0) | (gdf_primary['height_primary'].isna())
|
|
117
|
+
valid_weighted_height_mask = zero_or_nan_mask & gdf_primary['weighted_height'].notna()
|
|
118
|
+
gdf_primary.loc[valid_weighted_height_mask, 'height_primary'] = gdf_primary.loc[valid_weighted_height_mask, 'weighted_height']
|
|
119
|
+
gdf_primary['height_primary'] = gdf_primary['height_primary'].fillna(np.nan)
|
|
120
|
+
|
|
121
|
+
sjoin_gdf = gpd.sjoin(gdf_ref, gdf_primary, how='left', predicate='intersects')
|
|
122
|
+
non_intersect_mask = sjoin_gdf[f'{primary_id}_right'].isna()
|
|
123
|
+
non_intersect_ids = sjoin_gdf[non_intersect_mask][f'{ref_id}_left'].unique()
|
|
124
|
+
gdf_ref_non_intersect = gdf_ref[gdf_ref[ref_id].isin(non_intersect_ids)]
|
|
125
|
+
gdf_ref_non_intersect = gdf_ref_non_intersect.rename(columns={'height_ref': 'height'})
|
|
126
|
+
|
|
127
|
+
gdf_primary = gdf_primary.rename(columns={'height_primary': 'height'})
|
|
128
|
+
if 'weighted_height' in gdf_primary.columns:
|
|
129
|
+
gdf_primary.drop(columns='weighted_height', inplace=True)
|
|
130
|
+
|
|
131
|
+
final_gdf = pd.concat([gdf_primary, gdf_ref_non_intersect], ignore_index=True)
|
|
132
|
+
|
|
133
|
+
count_total = len(gdf_primary)
|
|
134
|
+
count_0 = len(gdf_primary[zero_or_nan_mask])
|
|
135
|
+
count_1 = len(gdf_primary[valid_weighted_height_mask])
|
|
136
|
+
count_2 = count_0 - count_1
|
|
137
|
+
count_3 = len(gdf_ref_non_intersect)
|
|
138
|
+
count_4 = count_3
|
|
139
|
+
height_mask = gdf_ref_non_intersect['height'].notna() & (gdf_ref_non_intersect['height'] > 0)
|
|
140
|
+
count_5 = len(gdf_ref_non_intersect[height_mask])
|
|
141
|
+
count_6 = count_4 - count_5
|
|
142
|
+
final_height_mask = final_gdf['height'].notna() & (final_gdf['height'] > 0)
|
|
143
|
+
count_7 = len(final_gdf[final_height_mask])
|
|
144
|
+
count_8 = len(final_gdf)
|
|
145
|
+
|
|
146
|
+
if count_0 > 0:
|
|
147
|
+
print(f"{count_0} of the total {count_total} building footprints from base data source did not have height data.")
|
|
148
|
+
print(f"For {count_1} of these building footprints without height, values from complementary data were assigned.")
|
|
149
|
+
print(f"For the rest {count_2}, no data exists in complementary data.")
|
|
150
|
+
print(f"Footprints of {count_3} buildings were added from the complementary source.")
|
|
151
|
+
print(f"Of these {count_4} additional building footprints, {count_5} had height data while {count_6} had no height data.")
|
|
152
|
+
print(f"In total, {count_7} buildings had height data out of {count_8} total building footprints.")
|
|
153
|
+
|
|
154
|
+
return final_gdf
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def extract_building_heights_from_geotiff(geotiff_path, gdf):
|
|
158
|
+
"""
|
|
159
|
+
Extract building heights from a GeoTIFF raster for building footprints in a GeoDataFrame.
|
|
160
|
+
"""
|
|
161
|
+
gdf = gdf.copy()
|
|
162
|
+
|
|
163
|
+
count_0 = 0
|
|
164
|
+
count_1 = 0
|
|
165
|
+
count_2 = 0
|
|
166
|
+
|
|
167
|
+
with rasterio.open(geotiff_path) as src:
|
|
168
|
+
transformer = Transformer.from_crs(CRS.from_epsg(4326), src.crs, always_xy=True)
|
|
169
|
+
|
|
170
|
+
mask_condition = (gdf.geometry.geom_type == 'Polygon') & ((gdf.get('height', 0) <= 0) | gdf.get('height').isna())
|
|
171
|
+
buildings_to_process = gdf[mask_condition]
|
|
172
|
+
count_0 = len(buildings_to_process)
|
|
173
|
+
|
|
174
|
+
for idx, row in buildings_to_process.iterrows():
|
|
175
|
+
coords = list(row.geometry.exterior.coords)
|
|
176
|
+
transformed_coords = [transformer.transform(lon, lat) for lon, lat in coords]
|
|
177
|
+
polygon = shape({"type": "Polygon", "coordinates": [transformed_coords]})
|
|
178
|
+
|
|
179
|
+
try:
|
|
180
|
+
masked_data, _ = rasterio.mask.mask(src, [polygon], crop=True, all_touched=True)
|
|
181
|
+
heights = masked_data[0][masked_data[0] != src.nodata]
|
|
182
|
+
if len(heights) > 0:
|
|
183
|
+
count_1 += 1
|
|
184
|
+
gdf.at[idx, 'height'] = float(np.mean(heights))
|
|
185
|
+
else:
|
|
186
|
+
count_2 += 1
|
|
187
|
+
gdf.at[idx, 'height'] = np.nan
|
|
188
|
+
except ValueError as e:
|
|
189
|
+
print(f"Error processing building at index {idx}. Error: {str(e)}")
|
|
190
|
+
gdf.at[idx, 'height'] = None
|
|
191
|
+
|
|
192
|
+
if count_0 > 0:
|
|
193
|
+
print(f"{count_0} of the total {len(gdf)} building footprint from OSM did not have height data.")
|
|
194
|
+
print(f"For {count_1} of these building footprints without height, values from complementary data were assigned.")
|
|
195
|
+
print(f"For {count_2} of these building footprints without height, no data exist in complementary data.")
|
|
196
|
+
|
|
197
|
+
return gdf
|
|
198
|
+
|
|
199
|
+
|