voxcity 0.6.26__py3-none-any.whl → 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. voxcity/__init__.py +14 -8
  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 +13 -5
  10. voxcity/exporter/cityles.py +633 -538
  11. voxcity/exporter/envimet.py +728 -708
  12. voxcity/exporter/magicavoxel.py +334 -297
  13. voxcity/exporter/netcdf.py +238 -211
  14. voxcity/exporter/obj.py +1481 -1406
  15. voxcity/generator/__init__.py +44 -0
  16. voxcity/generator/api.py +675 -0
  17. voxcity/generator/grids.py +379 -0
  18. voxcity/generator/io.py +94 -0
  19. voxcity/generator/pipeline.py +282 -0
  20. voxcity/generator/voxelizer.py +380 -0
  21. voxcity/geoprocessor/__init__.py +75 -6
  22. voxcity/geoprocessor/conversion.py +153 -0
  23. voxcity/geoprocessor/draw.py +62 -12
  24. voxcity/geoprocessor/heights.py +199 -0
  25. voxcity/geoprocessor/io.py +101 -0
  26. voxcity/geoprocessor/merge_utils.py +91 -0
  27. voxcity/geoprocessor/mesh.py +806 -790
  28. voxcity/geoprocessor/network.py +708 -679
  29. voxcity/geoprocessor/overlap.py +84 -0
  30. voxcity/geoprocessor/raster/__init__.py +82 -0
  31. voxcity/geoprocessor/raster/buildings.py +428 -0
  32. voxcity/geoprocessor/raster/canopy.py +258 -0
  33. voxcity/geoprocessor/raster/core.py +150 -0
  34. voxcity/geoprocessor/raster/export.py +93 -0
  35. voxcity/geoprocessor/raster/landcover.py +156 -0
  36. voxcity/geoprocessor/raster/raster.py +110 -0
  37. voxcity/geoprocessor/selection.py +85 -0
  38. voxcity/geoprocessor/utils.py +18 -14
  39. voxcity/models.py +113 -0
  40. voxcity/simulator/common/__init__.py +22 -0
  41. voxcity/simulator/common/geometry.py +98 -0
  42. voxcity/simulator/common/raytracing.py +450 -0
  43. voxcity/simulator/solar/__init__.py +43 -0
  44. voxcity/simulator/solar/integration.py +336 -0
  45. voxcity/simulator/solar/kernels.py +62 -0
  46. voxcity/simulator/solar/radiation.py +648 -0
  47. voxcity/simulator/solar/temporal.py +434 -0
  48. voxcity/simulator/view.py +36 -2286
  49. voxcity/simulator/visibility/__init__.py +29 -0
  50. voxcity/simulator/visibility/landmark.py +392 -0
  51. voxcity/simulator/visibility/view.py +508 -0
  52. voxcity/utils/logging.py +61 -0
  53. voxcity/utils/orientation.py +51 -0
  54. voxcity/utils/weather/__init__.py +26 -0
  55. voxcity/utils/weather/epw.py +146 -0
  56. voxcity/utils/weather/files.py +36 -0
  57. voxcity/utils/weather/onebuilding.py +486 -0
  58. voxcity/visualizer/__init__.py +24 -0
  59. voxcity/visualizer/builder.py +43 -0
  60. voxcity/visualizer/grids.py +141 -0
  61. voxcity/visualizer/maps.py +187 -0
  62. voxcity/visualizer/palette.py +228 -0
  63. voxcity/visualizer/renderer.py +928 -0
  64. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/METADATA +107 -34
  65. voxcity-0.7.0.dist-info/RECORD +77 -0
  66. voxcity/generator.py +0 -1302
  67. voxcity/geoprocessor/grid.py +0 -1739
  68. voxcity/geoprocessor/polygon.py +0 -1344
  69. voxcity/simulator/solar.py +0 -2339
  70. voxcity/utils/visualization.py +0 -2849
  71. voxcity/utils/weather.py +0 -1038
  72. voxcity-0.6.26.dist-info/RECORD +0 -38
  73. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/WHEEL +0 -0
  74. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/licenses/AUTHORS.rst +0 -0
  75. {voxcity-0.6.26.dist-info → voxcity-0.7.0.dist-info}/licenses/LICENSE +0 -0
@@ -1,6 +1,75 @@
1
- from .draw import *
2
- from .grid import *
3
- from .utils import *
4
- from .network import *
5
- from .polygon import *
6
- from .mesh import *
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
+
@@ -24,7 +24,7 @@ Dependencies:
24
24
  """
25
25
 
26
26
  import math
27
- from pyproj import Proj, transform
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 projections - need to convert between coordinate systems for accurate rotation
86
- wgs84 = Proj(init='epsg:4326') # WGS84 lat-lon (standard GPS coordinates)
87
- mercator = Proj(init='epsg:3857') # Web Mercator (projection used by most web maps)
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(wgs84, mercator, lon, lat) for lon, lat in rectangle_vertices]
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(mercator, wgs84, x, y) for x, y in rotated_vertices]
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, only the base map is displayed.
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, creates a new empty GeoDataFrame.
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
- Example:
551
- >>> # Start with empty buildings
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
+