voxcity 0.7.0__py3-none-any.whl → 1.0.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voxcity/__init__.py +14 -14
- voxcity/exporter/__init__.py +12 -12
- voxcity/exporter/cityles.py +633 -633
- voxcity/exporter/envimet.py +733 -728
- voxcity/exporter/magicavoxel.py +333 -333
- voxcity/exporter/netcdf.py +238 -238
- voxcity/exporter/obj.py +1480 -1480
- voxcity/generator/__init__.py +47 -44
- voxcity/generator/api.py +721 -675
- voxcity/generator/grids.py +381 -379
- voxcity/generator/io.py +94 -94
- voxcity/generator/pipeline.py +282 -282
- voxcity/generator/update.py +429 -0
- voxcity/generator/voxelizer.py +18 -6
- voxcity/geoprocessor/__init__.py +75 -75
- voxcity/geoprocessor/draw.py +1488 -1219
- voxcity/geoprocessor/merge_utils.py +91 -91
- voxcity/geoprocessor/mesh.py +806 -806
- voxcity/geoprocessor/network.py +708 -708
- voxcity/geoprocessor/raster/buildings.py +435 -428
- voxcity/geoprocessor/raster/export.py +93 -93
- voxcity/geoprocessor/raster/landcover.py +5 -2
- voxcity/geoprocessor/utils.py +824 -824
- voxcity/models.py +113 -113
- voxcity/simulator/solar/__init__.py +66 -43
- voxcity/simulator/solar/integration.py +336 -336
- voxcity/simulator/solar/sky.py +668 -0
- voxcity/simulator/solar/temporal.py +792 -434
- voxcity/utils/__init__.py +11 -0
- voxcity/utils/classes.py +194 -0
- voxcity/utils/lc.py +80 -39
- voxcity/utils/shape.py +230 -0
- voxcity/visualizer/__init__.py +24 -24
- voxcity/visualizer/builder.py +43 -43
- voxcity/visualizer/grids.py +141 -141
- voxcity/visualizer/maps.py +187 -187
- voxcity/visualizer/renderer.py +1145 -928
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/METADATA +90 -49
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/RECORD +42 -38
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/WHEEL +0 -0
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-0.7.0.dist-info → voxcity-1.0.2.dist-info}/licenses/LICENSE +0 -0
voxcity/geoprocessor/utils.py
CHANGED
|
@@ -1,825 +1,825 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Utility functions for geographic operations and coordinate transformations.
|
|
3
|
-
|
|
4
|
-
This module provides various utility functions for working with geographic data,
|
|
5
|
-
including coordinate transformations, distance calculations, geocoding, and building
|
|
6
|
-
polygon processing. It supports operations such as:
|
|
7
|
-
|
|
8
|
-
- Tile coordinate calculations and quadkey conversions
|
|
9
|
-
- Geographic distance calculations (Haversine and geodetic)
|
|
10
|
-
- Coordinate system transformations
|
|
11
|
-
- Polygon and GeoDataFrame operations
|
|
12
|
-
- Raster file processing and merging
|
|
13
|
-
- Geocoding and reverse geocoding
|
|
14
|
-
- Timezone and location information retrieval
|
|
15
|
-
- Building polygon validation and processing
|
|
16
|
-
|
|
17
|
-
The module uses several external libraries for geographic operations:
|
|
18
|
-
- pyproj: For coordinate transformations and geodetic calculations
|
|
19
|
-
- geopandas: For handling geographic data frames
|
|
20
|
-
- rasterio: For raster file operations
|
|
21
|
-
- shapely: For geometric operations
|
|
22
|
-
- geopy: For geocoding services
|
|
23
|
-
- timezonefinder: For timezone lookups
|
|
24
|
-
"""
|
|
25
|
-
|
|
26
|
-
# Standard library imports
|
|
27
|
-
import os
|
|
28
|
-
import math
|
|
29
|
-
from math import radians, sin, cos, sqrt, atan2
|
|
30
|
-
from datetime import datetime
|
|
31
|
-
|
|
32
|
-
# Third-party geographic processing libraries
|
|
33
|
-
import numpy as np
|
|
34
|
-
from pyproj import Geod, Transformer
|
|
35
|
-
import geopandas as gpd
|
|
36
|
-
import rasterio
|
|
37
|
-
from rasterio.merge import merge
|
|
38
|
-
from rasterio.warp import transform_bounds
|
|
39
|
-
from rasterio.mask import mask
|
|
40
|
-
from shapely.geometry import Polygon, box
|
|
41
|
-
from fiona.crs import from_epsg
|
|
42
|
-
from rtree import index
|
|
43
|
-
|
|
44
|
-
# Geocoding and location services
|
|
45
|
-
from geopy.geocoders import Nominatim
|
|
46
|
-
from geopy.exc import GeocoderTimedOut, GeocoderServiceError, GeocoderInsufficientPrivileges
|
|
47
|
-
from geopy.extra.rate_limiter import RateLimiter
|
|
48
|
-
import reverse_geocoder as rg
|
|
49
|
-
import pycountry
|
|
50
|
-
|
|
51
|
-
# Timezone handling
|
|
52
|
-
from timezonefinder import TimezoneFinder
|
|
53
|
-
import pytz
|
|
54
|
-
|
|
55
|
-
# Suppress rasterio warnings for non-georeferenced files
|
|
56
|
-
import warnings
|
|
57
|
-
warnings.filterwarnings("ignore", category=rasterio.errors.NotGeoreferencedWarning)
|
|
58
|
-
|
|
59
|
-
# Global constants
|
|
60
|
-
floor_height = 2.5 # Standard floor height in meters used for building height calculations
|
|
61
|
-
|
|
62
|
-
# Package logging
|
|
63
|
-
from ..utils.logging import get_logger
|
|
64
|
-
logger = get_logger(__name__)
|
|
65
|
-
|
|
66
|
-
# Build a compliant Nominatim user agent once and reuse it
|
|
67
|
-
try:
|
|
68
|
-
# Prefer package metadata if available
|
|
69
|
-
from voxcity import __version__ as _vox_version, __email__ as _vox_email
|
|
70
|
-
except Exception:
|
|
71
|
-
_vox_version, _vox_email = "dev", "contact@voxcity.local"
|
|
72
|
-
|
|
73
|
-
_ENV_UA = os.environ.get("VOXCITY_NOMINATIM_UA", "").strip()
|
|
74
|
-
_DEFAULT_UA = f"voxcity/{_vox_version} (+https://github.com/kunifujiwara/voxcity; contact: {_vox_email})"
|
|
75
|
-
_NOMINATIM_USER_AGENT = _ENV_UA or _DEFAULT_UA
|
|
76
|
-
|
|
77
|
-
def _create_nominatim_geolocator() -> Nominatim:
|
|
78
|
-
"""
|
|
79
|
-
Create a Nominatim geolocator with a compliant identifying user agent.
|
|
80
|
-
The user agent can be overridden via the environment variable
|
|
81
|
-
VOXCITY_NOMINATIM_UA.
|
|
82
|
-
"""
|
|
83
|
-
return Nominatim(user_agent=_NOMINATIM_USER_AGENT)
|
|
84
|
-
|
|
85
|
-
def tile_from_lat_lon(lat, lon, level_of_detail):
|
|
86
|
-
"""
|
|
87
|
-
Convert latitude/longitude coordinates to tile coordinates at a given zoom level.
|
|
88
|
-
Uses the Web Mercator projection (EPSG:3857) commonly used in web mapping.
|
|
89
|
-
|
|
90
|
-
Args:
|
|
91
|
-
lat (float): Latitude in degrees (-90 to 90)
|
|
92
|
-
lon (float): Longitude in degrees (-180 to 180)
|
|
93
|
-
level_of_detail (int): Zoom level (0-23, where 0 is the entire world)
|
|
94
|
-
|
|
95
|
-
Returns:
|
|
96
|
-
tuple: (tile_x, tile_y) tile coordinates in the global tile grid
|
|
97
|
-
|
|
98
|
-
Example:
|
|
99
|
-
>>> tile_x, tile_y = tile_from_lat_lon(35.6762, 139.6503, 12) # Tokyo at zoom 12
|
|
100
|
-
"""
|
|
101
|
-
# Convert latitude to radians and calculate sine
|
|
102
|
-
sin_lat = math.sin(lat * math.pi / 180)
|
|
103
|
-
|
|
104
|
-
# Convert longitude to normalized x coordinate (0-1)
|
|
105
|
-
x = (lon + 180) / 360
|
|
106
|
-
|
|
107
|
-
# Convert latitude to y coordinate using Mercator projection formula
|
|
108
|
-
y = 0.5 - math.log((1 + sin_lat) / (1 - sin_lat)) / (4 * math.pi)
|
|
109
|
-
|
|
110
|
-
# Calculate map size in pixels at this zoom level (256 * 2^zoom)
|
|
111
|
-
map_size = 256 << level_of_detail
|
|
112
|
-
|
|
113
|
-
# Convert x,y to tile coordinates
|
|
114
|
-
tile_x = int(x * map_size / 256)
|
|
115
|
-
tile_y = int(y * map_size / 256)
|
|
116
|
-
return tile_x, tile_y
|
|
117
|
-
|
|
118
|
-
def quadkey_to_tile(quadkey):
|
|
119
|
-
"""
|
|
120
|
-
Convert a quadkey string to tile coordinates.
|
|
121
|
-
A quadkey is a string of digits (0-3) that identifies a tile at a certain zoom level.
|
|
122
|
-
Each digit in the quadkey represents a tile at a zoom level, with each subsequent digit
|
|
123
|
-
representing a more detailed zoom level.
|
|
124
|
-
|
|
125
|
-
The quadkey numbering scheme:
|
|
126
|
-
- 0: Top-left quadrant
|
|
127
|
-
- 1: Top-right quadrant
|
|
128
|
-
- 2: Bottom-left quadrant
|
|
129
|
-
- 3: Bottom-right quadrant
|
|
130
|
-
|
|
131
|
-
Args:
|
|
132
|
-
quadkey (str): Quadkey string (e.g., "120" for zoom level 3)
|
|
133
|
-
|
|
134
|
-
Returns:
|
|
135
|
-
tuple: (tile_x, tile_y, level_of_detail) tile coordinates and zoom level
|
|
136
|
-
|
|
137
|
-
Example:
|
|
138
|
-
>>> x, y, zoom = quadkey_to_tile("120") # Returns coordinates at zoom level 3
|
|
139
|
-
"""
|
|
140
|
-
tile_x = tile_y = 0
|
|
141
|
-
level_of_detail = len(quadkey)
|
|
142
|
-
|
|
143
|
-
# Process each character in quadkey
|
|
144
|
-
for i in range(level_of_detail):
|
|
145
|
-
bit = level_of_detail - i - 1
|
|
146
|
-
mask = 1 << bit
|
|
147
|
-
|
|
148
|
-
# Quadkey digit to binary:
|
|
149
|
-
# 0 = neither x nor y bit set
|
|
150
|
-
# 1 = x bit set
|
|
151
|
-
# 2 = y bit set
|
|
152
|
-
# 3 = both x and y bits set
|
|
153
|
-
if quadkey[i] == '1':
|
|
154
|
-
tile_x |= mask
|
|
155
|
-
elif quadkey[i] == '2':
|
|
156
|
-
tile_y |= mask
|
|
157
|
-
elif quadkey[i] == '3':
|
|
158
|
-
tile_x |= mask
|
|
159
|
-
tile_y |= mask
|
|
160
|
-
return tile_x, tile_y, level_of_detail
|
|
161
|
-
|
|
162
|
-
def initialize_geod():
|
|
163
|
-
"""
|
|
164
|
-
Initialize a Geod object for geodetic calculations using WGS84 ellipsoid.
|
|
165
|
-
The WGS84 ellipsoid (EPSG:4326) is the standard reference system used by GPS
|
|
166
|
-
and most modern mapping applications.
|
|
167
|
-
|
|
168
|
-
The Geod object provides methods for:
|
|
169
|
-
- Forward geodetic calculations (direct)
|
|
170
|
-
- Inverse geodetic calculations (inverse)
|
|
171
|
-
- Area calculations
|
|
172
|
-
- Line length calculations
|
|
173
|
-
|
|
174
|
-
Returns:
|
|
175
|
-
Geod: Initialized Geod object for WGS84 calculations
|
|
176
|
-
|
|
177
|
-
Example:
|
|
178
|
-
>>> geod = initialize_geod()
|
|
179
|
-
>>> fwd_az, back_az, dist = geod.inv(lon1, lat1, lon2, lat2)
|
|
180
|
-
"""
|
|
181
|
-
return Geod(ellps='WGS84')
|
|
182
|
-
|
|
183
|
-
def calculate_distance(geod, lon1, lat1, lon2, lat2):
|
|
184
|
-
"""
|
|
185
|
-
Calculate geodetic distance between two points on the Earth's surface.
|
|
186
|
-
Uses inverse geodetic computation to find the shortest distance along the ellipsoid,
|
|
187
|
-
which is more accurate than great circle (spherical) calculations.
|
|
188
|
-
|
|
189
|
-
Args:
|
|
190
|
-
geod (Geod): Geod object for calculations, initialized with WGS84
|
|
191
|
-
lon1, lat1 (float): Coordinates of first point in decimal degrees
|
|
192
|
-
lon2, lat2 (float): Coordinates of second point in decimal degrees
|
|
193
|
-
|
|
194
|
-
Returns:
|
|
195
|
-
float: Distance in meters between the two points along the ellipsoid
|
|
196
|
-
|
|
197
|
-
Example:
|
|
198
|
-
>>> geod = initialize_geod()
|
|
199
|
-
>>> distance = calculate_distance(geod, 139.6503, 35.6762,
|
|
200
|
-
... -74.0060, 40.7128) # Tokyo to NYC
|
|
201
|
-
"""
|
|
202
|
-
# inv() returns forward azimuth, back azimuth, and distance
|
|
203
|
-
_, _, dist = geod.inv(lon1, lat1, lon2, lat2)
|
|
204
|
-
return dist
|
|
205
|
-
|
|
206
|
-
def normalize_to_one_meter(vector, distance_in_meters):
|
|
207
|
-
"""
|
|
208
|
-
Normalize a vector to represent one meter in geographic space.
|
|
209
|
-
Useful for creating unit vectors in geographic calculations, particularly
|
|
210
|
-
when working with distance-based operations or scaling geographic features.
|
|
211
|
-
|
|
212
|
-
Args:
|
|
213
|
-
vector (numpy.ndarray): Vector to normalize, typically a direction vector
|
|
214
|
-
distance_in_meters (float): Current distance in meters that the vector represents
|
|
215
|
-
|
|
216
|
-
Returns:
|
|
217
|
-
numpy.ndarray: Normalized vector where magnitude represents 1 meter
|
|
218
|
-
|
|
219
|
-
Example:
|
|
220
|
-
>>> direction = np.array([3.0, 4.0]) # Vector of length 5
|
|
221
|
-
>>> unit_meter = normalize_to_one_meter(direction, 5.0)
|
|
222
|
-
"""
|
|
223
|
-
return vector * (1 / distance_in_meters)
|
|
224
|
-
|
|
225
|
-
def setup_transformer(from_crs, to_crs):
|
|
226
|
-
"""
|
|
227
|
-
Set up a coordinate transformer between two Coordinate Reference Systems (CRS).
|
|
228
|
-
The always_xy=True parameter ensures consistent handling of coordinate order
|
|
229
|
-
by always using (x,y) or (longitude,latitude) order regardless of CRS definition.
|
|
230
|
-
|
|
231
|
-
Common CRS codes:
|
|
232
|
-
- EPSG:4326 - WGS84 (latitude/longitude)
|
|
233
|
-
- EPSG:3857 - Web Mercator
|
|
234
|
-
- EPSG:2263 - NY State Plane
|
|
235
|
-
|
|
236
|
-
Args:
|
|
237
|
-
from_crs: Source coordinate reference system (EPSG code, proj4 string, or CRS dict)
|
|
238
|
-
to_crs: Target coordinate reference system (EPSG code, proj4 string, or CRS dict)
|
|
239
|
-
|
|
240
|
-
Returns:
|
|
241
|
-
Transformer: Initialized transformer object for coordinate conversion
|
|
242
|
-
|
|
243
|
-
Example:
|
|
244
|
-
>>> transformer = setup_transformer("EPSG:4326", "EPSG:3857")
|
|
245
|
-
>>> x, y = transformer.transform(longitude, latitude)
|
|
246
|
-
"""
|
|
247
|
-
return Transformer.from_crs(from_crs, to_crs, always_xy=True)
|
|
248
|
-
|
|
249
|
-
def transform_coords(transformer, lon, lat):
|
|
250
|
-
"""
|
|
251
|
-
Transform coordinates using provided transformer with error handling.
|
|
252
|
-
Includes validation for infinite values that may result from invalid transformations
|
|
253
|
-
or coordinates outside the valid range for the target CRS.
|
|
254
|
-
|
|
255
|
-
Args:
|
|
256
|
-
transformer (Transformer): Coordinate transformer from setup_transformer()
|
|
257
|
-
lon, lat (float): Input coordinates in the source CRS
|
|
258
|
-
|
|
259
|
-
Returns:
|
|
260
|
-
tuple: (x, y) transformed coordinates in the target CRS, or (None, None) if transformation fails
|
|
261
|
-
|
|
262
|
-
Example:
|
|
263
|
-
>>> transformer = setup_transformer("EPSG:4326", "EPSG:3857")
|
|
264
|
-
>>> x, y = transform_coords(transformer, -74.0060, 40.7128) # NYC coordinates
|
|
265
|
-
>>> if x is not None:
|
|
266
|
-
... print(f"Transformed coordinates: ({x}, {y})")
|
|
267
|
-
"""
|
|
268
|
-
try:
|
|
269
|
-
x, y = transformer.transform(lon, lat)
|
|
270
|
-
if np.isinf(x) or np.isinf(y):
|
|
271
|
-
logger.warning("Transformation resulted in inf values for coordinates: %s, %s", lon, lat)
|
|
272
|
-
return x, y
|
|
273
|
-
except Exception as e:
|
|
274
|
-
logger.error("Error transforming coordinates %s, %s: %s", lon, lat, e)
|
|
275
|
-
return None, None
|
|
276
|
-
|
|
277
|
-
def create_polygon(vertices):
|
|
278
|
-
"""
|
|
279
|
-
Create a Shapely polygon from a list of vertices.
|
|
280
|
-
Input vertices must be in (longitude, latitude) format as required by Shapely.
|
|
281
|
-
The polygon will be automatically closed if the first and last vertices don't match.
|
|
282
|
-
|
|
283
|
-
Args:
|
|
284
|
-
vertices (list): List of (longitude, latitude) coordinate pairs forming the polygon.
|
|
285
|
-
The coordinates should be in counter-clockwise order for exterior rings
|
|
286
|
-
and clockwise order for interior rings (holes).
|
|
287
|
-
|
|
288
|
-
Returns:
|
|
289
|
-
Polygon: Shapely polygon object that can be used for spatial operations
|
|
290
|
-
|
|
291
|
-
Example:
|
|
292
|
-
>>> vertices = [(0, 0), (1, 0), (1, 1), (0, 1)] # Square
|
|
293
|
-
>>> polygon = create_polygon(vertices)
|
|
294
|
-
>>> print(f"Polygon area: {polygon.area}")
|
|
295
|
-
"""
|
|
296
|
-
return Polygon(vertices)
|
|
297
|
-
|
|
298
|
-
def create_geodataframe(polygon, crs=4326):
|
|
299
|
-
"""
|
|
300
|
-
Create a GeoDataFrame from a Shapely polygon.
|
|
301
|
-
Default CRS is WGS84 (EPSG:4326) for geographic coordinates.
|
|
302
|
-
The GeoDataFrame provides additional functionality for spatial operations,
|
|
303
|
-
data analysis, and export to various geographic formats.
|
|
304
|
-
|
|
305
|
-
Args:
|
|
306
|
-
polygon (Polygon): Shapely polygon object to convert
|
|
307
|
-
crs (int): Coordinate reference system EPSG code (default: 4326 for WGS84)
|
|
308
|
-
|
|
309
|
-
Returns:
|
|
310
|
-
GeoDataFrame: GeoDataFrame containing the polygon with specified CRS
|
|
311
|
-
|
|
312
|
-
Example:
|
|
313
|
-
>>> vertices = [(0, 0), (1, 0), (1, 1), (0, 1)]
|
|
314
|
-
>>> polygon = create_polygon(vertices)
|
|
315
|
-
>>> gdf = create_geodataframe(polygon)
|
|
316
|
-
>>> gdf.to_file("polygon.geojson", driver="GeoJSON")
|
|
317
|
-
"""
|
|
318
|
-
return gpd.GeoDataFrame({'geometry': [polygon]}, crs=from_epsg(crs))
|
|
319
|
-
|
|
320
|
-
def haversine_distance(lon1, lat1, lon2, lat2):
|
|
321
|
-
"""
|
|
322
|
-
Calculate great-circle distance between two points using Haversine formula.
|
|
323
|
-
This is an approximation that treats the Earth as a perfect sphere.
|
|
324
|
-
|
|
325
|
-
Args:
|
|
326
|
-
lon1, lat1 (float): Coordinates of first point
|
|
327
|
-
lon2, lat2 (float): Coordinates of second point
|
|
328
|
-
|
|
329
|
-
Returns:
|
|
330
|
-
float: Distance in kilometers
|
|
331
|
-
"""
|
|
332
|
-
R = 6371 # Earth's radius in kilometers
|
|
333
|
-
|
|
334
|
-
# Convert all coordinates to radians
|
|
335
|
-
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
|
|
336
|
-
|
|
337
|
-
# Calculate differences
|
|
338
|
-
dlat = lat2 - lat1
|
|
339
|
-
dlon = lon2 - lon1
|
|
340
|
-
|
|
341
|
-
# Haversine formula
|
|
342
|
-
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
|
|
343
|
-
c = 2 * atan2(sqrt(a), sqrt(1-a))
|
|
344
|
-
return R * c
|
|
345
|
-
|
|
346
|
-
def get_raster_bbox(raster_path):
|
|
347
|
-
"""
|
|
348
|
-
Get the bounding box of a raster file in its native coordinate system.
|
|
349
|
-
Returns a rectangular polygon representing the spatial extent of the raster,
|
|
350
|
-
which can be used for spatial queries and intersection tests.
|
|
351
|
-
|
|
352
|
-
Args:
|
|
353
|
-
raster_path (str): Path to the raster file (GeoTIFF, IMG, etc.)
|
|
354
|
-
|
|
355
|
-
Returns:
|
|
356
|
-
box: Shapely box representing the raster bounds in the raster's CRS
|
|
357
|
-
|
|
358
|
-
Example:
|
|
359
|
-
>>> bbox = get_raster_bbox("elevation.tif")
|
|
360
|
-
>>> print(f"Raster extent: {bbox.bounds}") # (minx, miny, maxx, maxy)
|
|
361
|
-
"""
|
|
362
|
-
with rasterio.open(raster_path) as src:
|
|
363
|
-
bounds = src.bounds
|
|
364
|
-
return box(bounds.left, bounds.bottom, bounds.right, bounds.top)
|
|
365
|
-
|
|
366
|
-
def raster_intersects_polygon(raster_path, polygon):
|
|
367
|
-
"""
|
|
368
|
-
Check if a raster file's extent intersects with a given polygon.
|
|
369
|
-
Automatically handles coordinate system transformations by converting
|
|
370
|
-
the raster bounds to WGS84 (EPSG:4326) if needed before the intersection test.
|
|
371
|
-
|
|
372
|
-
Args:
|
|
373
|
-
raster_path (str): Path to the raster file to check
|
|
374
|
-
polygon (Polygon): Shapely polygon to test intersection with (in WGS84)
|
|
375
|
-
|
|
376
|
-
Returns:
|
|
377
|
-
bool: True if raster intersects or contains the polygon, False otherwise
|
|
378
|
-
|
|
379
|
-
Example:
|
|
380
|
-
>>> aoi = create_polygon([(lon1, lat1), (lon2, lat2), ...]) # Area of interest
|
|
381
|
-
>>> if raster_intersects_polygon("dem.tif", aoi):
|
|
382
|
-
... print("Raster covers the area of interest")
|
|
383
|
-
"""
|
|
384
|
-
with rasterio.open(raster_path) as src:
|
|
385
|
-
bounds = src.bounds
|
|
386
|
-
# Transform bounds to WGS84 if raster is in different CRS
|
|
387
|
-
if src.crs.to_epsg() != 4326:
|
|
388
|
-
bounds = transform_bounds(src.crs, 'EPSG:4326', *bounds)
|
|
389
|
-
raster_bbox = box(*bounds)
|
|
390
|
-
intersects = raster_bbox.intersects(polygon) or polygon.intersects(raster_bbox)
|
|
391
|
-
return intersects
|
|
392
|
-
|
|
393
|
-
def save_raster(input_path, output_path):
|
|
394
|
-
"""
|
|
395
|
-
Create a copy of a raster file at a new location.
|
|
396
|
-
Performs a direct file copy without any transformation or modification,
|
|
397
|
-
preserving all metadata, georeferencing, and pixel values.
|
|
398
|
-
|
|
399
|
-
Args:
|
|
400
|
-
input_path (str): Source raster file path
|
|
401
|
-
output_path (str): Destination path for the copied raster
|
|
402
|
-
|
|
403
|
-
Example:
|
|
404
|
-
>>> save_raster("original.tif", "backup/copy.tif")
|
|
405
|
-
>>> print("Copied original file to: backup/copy.tif")
|
|
406
|
-
"""
|
|
407
|
-
import shutil
|
|
408
|
-
shutil.copy(input_path, output_path)
|
|
409
|
-
logger.info("Copied original file to: %s", output_path)
|
|
410
|
-
|
|
411
|
-
def merge_geotiffs(geotiff_files, output_dir):
|
|
412
|
-
"""
|
|
413
|
-
Merge multiple GeoTIFF files into a single mosaic.
|
|
414
|
-
Handles edge matching and overlapping areas between adjacent rasters.
|
|
415
|
-
The output will have the same coordinate system and data type as the input files.
|
|
416
|
-
|
|
417
|
-
Important considerations:
|
|
418
|
-
- All input files should have the same coordinate system
|
|
419
|
-
- All input files should have the same data type
|
|
420
|
-
- Overlapping areas are handled by taking the first value encountered
|
|
421
|
-
|
|
422
|
-
Args:
|
|
423
|
-
geotiff_files (list): List of paths to GeoTIFF files to merge
|
|
424
|
-
output_dir (str): Directory where the merged output will be saved
|
|
425
|
-
|
|
426
|
-
Example:
|
|
427
|
-
>>> files = ["tile1.tif", "tile2.tif", "tile3.tif"]
|
|
428
|
-
>>> merge_geotiffs(files, "output_directory")
|
|
429
|
-
>>> print("Merged output saved to: output_directory/lulc.tif")
|
|
430
|
-
"""
|
|
431
|
-
if not geotiff_files:
|
|
432
|
-
return
|
|
433
|
-
|
|
434
|
-
# Open all valid GeoTIFF files
|
|
435
|
-
src_files_to_mosaic = [rasterio.open(file) for file in geotiff_files if os.path.exists(file)]
|
|
436
|
-
|
|
437
|
-
if src_files_to_mosaic:
|
|
438
|
-
try:
|
|
439
|
-
# Merge rasters into a single mosaic and get output transform
|
|
440
|
-
mosaic, out_trans = merge(src_files_to_mosaic)
|
|
441
|
-
|
|
442
|
-
# Copy metadata from first raster and update for merged output
|
|
443
|
-
out_meta = src_files_to_mosaic[0].meta.copy()
|
|
444
|
-
out_meta.update({
|
|
445
|
-
"driver": "GTiff",
|
|
446
|
-
"height": mosaic.shape[1],
|
|
447
|
-
"width": mosaic.shape[2],
|
|
448
|
-
"transform": out_trans
|
|
449
|
-
})
|
|
450
|
-
|
|
451
|
-
# Save merged raster to output file
|
|
452
|
-
merged_path = os.path.join(output_dir, "lulc.tif")
|
|
453
|
-
with rasterio.open(merged_path, "w", **out_meta) as dest:
|
|
454
|
-
dest.write(mosaic)
|
|
455
|
-
|
|
456
|
-
logger.info("Merged output saved to: %s", merged_path)
|
|
457
|
-
except Exception as e:
|
|
458
|
-
logger.error("Error merging files: %s", e)
|
|
459
|
-
else:
|
|
460
|
-
logger.info("No valid files to merge.")
|
|
461
|
-
|
|
462
|
-
# Clean up by closing all opened files
|
|
463
|
-
for src in src_files_to_mosaic:
|
|
464
|
-
src.close()
|
|
465
|
-
|
|
466
|
-
def convert_format_lat_lon(input_coords):
|
|
467
|
-
"""
|
|
468
|
-
Convert coordinate format and close polygon.
|
|
469
|
-
Input coordinates are already in [lon, lat] format.
|
|
470
|
-
|
|
471
|
-
Args:
|
|
472
|
-
input_coords (list): List of [lon, lat] coordinates
|
|
473
|
-
|
|
474
|
-
Returns:
|
|
475
|
-
list: List of [lon, lat] coordinates with first point repeated at end
|
|
476
|
-
"""
|
|
477
|
-
# Create list with coordinates in same order
|
|
478
|
-
output_coords = input_coords.copy()
|
|
479
|
-
# Close polygon by repeating first point at end
|
|
480
|
-
output_coords.append(output_coords[0])
|
|
481
|
-
return output_coords
|
|
482
|
-
|
|
483
|
-
def get_coordinates_from_cityname(place_name):
|
|
484
|
-
"""
|
|
485
|
-
Geocode a city name to get its coordinates using OpenStreetMap's Nominatim service.
|
|
486
|
-
Includes rate limiting and error handling to comply with Nominatim's usage policy.
|
|
487
|
-
|
|
488
|
-
Note:
|
|
489
|
-
- Results may vary based on the specificity of the place name
|
|
490
|
-
- For better results, include country or state information
|
|
491
|
-
- Service has usage limits and may timeout
|
|
492
|
-
|
|
493
|
-
Args:
|
|
494
|
-
place_name (str): Name of the city to geocode (e.g., "Tokyo, Japan")
|
|
495
|
-
|
|
496
|
-
Returns:
|
|
497
|
-
tuple: (latitude, longitude) coordinates or None if geocoding fails
|
|
498
|
-
|
|
499
|
-
Example:
|
|
500
|
-
>>> coords = get_coordinates_from_cityname("Paris, France")
|
|
501
|
-
>>> if coords:
|
|
502
|
-
... lat, lon = coords
|
|
503
|
-
... print(f"Paris coordinates: {lat}, {lon}")
|
|
504
|
-
"""
|
|
505
|
-
# Initialize geocoder with compliant user agent
|
|
506
|
-
geolocator = _create_nominatim_geolocator()
|
|
507
|
-
geocode_once = RateLimiter(geolocator.geocode, min_delay_seconds=1.0, max_retries=0)
|
|
508
|
-
|
|
509
|
-
try:
|
|
510
|
-
# Attempt to geocode the place name (single try; no retries on 403)
|
|
511
|
-
location = geocode_once(place_name, exactly_one=True, timeout=10)
|
|
512
|
-
|
|
513
|
-
if location:
|
|
514
|
-
return (location.latitude, location.longitude)
|
|
515
|
-
else:
|
|
516
|
-
return None
|
|
517
|
-
except GeocoderInsufficientPrivileges:
|
|
518
|
-
logger.warning("Nominatim blocked the request (HTTP 403). Please set a proper user agent and avoid bulk requests.")
|
|
519
|
-
return None
|
|
520
|
-
except (GeocoderTimedOut, GeocoderServiceError):
|
|
521
|
-
logger.error("Geocoding service timed out or encountered an error for %s", place_name)
|
|
522
|
-
return None
|
|
523
|
-
|
|
524
|
-
def get_city_country_name_from_rectangle(coordinates):
|
|
525
|
-
"""
|
|
526
|
-
Get the city and country name for a location defined by a rectangle.
|
|
527
|
-
Uses reverse geocoding to find the nearest named place to the rectangle's center.
|
|
528
|
-
|
|
529
|
-
The function:
|
|
530
|
-
1. Calculates the center point of the rectangle
|
|
531
|
-
2. Performs reverse geocoding with rate limiting
|
|
532
|
-
3. Extracts city and country information from the result
|
|
533
|
-
|
|
534
|
-
Args:
|
|
535
|
-
coordinates (list): List of (longitude, latitude) coordinates defining the rectangle
|
|
536
|
-
|
|
537
|
-
Returns:
|
|
538
|
-
str: String in format "city/ country" or fallback value if lookup fails
|
|
539
|
-
|
|
540
|
-
Example:
|
|
541
|
-
>>> coords = [(139.65, 35.67), (139.66, 35.67),
|
|
542
|
-
... (139.66, 35.68), (139.65, 35.68)]
|
|
543
|
-
>>> location = get_city_country_name_from_rectangle(coords)
|
|
544
|
-
>>> print(f"Location: {location}") # e.g., "Shibuya/ Japan"
|
|
545
|
-
"""
|
|
546
|
-
# Calculate center point of rectangle
|
|
547
|
-
longitudes = [coord[0] for coord in coordinates]
|
|
548
|
-
latitudes = [coord[1] for coord in coordinates]
|
|
549
|
-
center_lon = sum(longitudes) / len(longitudes)
|
|
550
|
-
center_lat = sum(latitudes) / len(latitudes)
|
|
551
|
-
center_coord = (center_lat, center_lon)
|
|
552
|
-
|
|
553
|
-
# Initialize geocoder with compliant user agent and conservative rate limit (1 req/sec)
|
|
554
|
-
geolocator = _create_nominatim_geolocator()
|
|
555
|
-
reverse_once = RateLimiter(geolocator.reverse, min_delay_seconds=1.0, max_retries=0)
|
|
556
|
-
|
|
557
|
-
try:
|
|
558
|
-
# Attempt reverse geocoding of center coordinates (single try; no retries on 403)
|
|
559
|
-
location = reverse_once(center_coord, language='en', exactly_one=True, timeout=10)
|
|
560
|
-
if location:
|
|
561
|
-
address = location.raw['address']
|
|
562
|
-
# Try multiple address fields to find city name, falling back to county if needed
|
|
563
|
-
city = address.get('city', '') or address.get('town', '') or address.get('village', '') or address.get('county', '')
|
|
564
|
-
country = address.get('country', '')
|
|
565
|
-
return f"{city}/ {country}"
|
|
566
|
-
else:
|
|
567
|
-
logger.info("Reverse geocoding location not found for %s", center_coord)
|
|
568
|
-
return "Unknown Location/ Unknown Country"
|
|
569
|
-
except GeocoderInsufficientPrivileges:
|
|
570
|
-
# Fallback to offline reverse_geocoder at coarse resolution
|
|
571
|
-
try:
|
|
572
|
-
results = rg.search((center_lat, center_lon))
|
|
573
|
-
name = results[0].get('name') or ''
|
|
574
|
-
country = get_country_name(center_lon, center_lat) or ''
|
|
575
|
-
if name or country:
|
|
576
|
-
return f"{name}/ {country}".strip()
|
|
577
|
-
except Exception:
|
|
578
|
-
pass
|
|
579
|
-
logger.warning("Nominatim blocked the request (HTTP 403). Falling back to offline coarse reverse geocoding.")
|
|
580
|
-
return "Unknown Location/ Unknown Country"
|
|
581
|
-
except (GeocoderTimedOut, GeocoderServiceError) as e:
|
|
582
|
-
logger.error("Error retrieving location for %s: %s", center_coord, e)
|
|
583
|
-
return "Unknown Location/ Unknown Country"
|
|
584
|
-
|
|
585
|
-
def get_timezone_info(rectangle_coords):
|
|
586
|
-
"""
|
|
587
|
-
Get timezone and central meridian information for a location.
|
|
588
|
-
Uses the rectangle's center point to determine the local timezone and
|
|
589
|
-
calculates the central meridian based on the UTC offset.
|
|
590
|
-
|
|
591
|
-
The function provides:
|
|
592
|
-
1. Local timezone identifier (e.g., "America/New_York")
|
|
593
|
-
2. UTC offset (e.g., "UTC-04:00")
|
|
594
|
-
3. Central meridian longitude for the timezone
|
|
595
|
-
|
|
596
|
-
Args:
|
|
597
|
-
rectangle_coords (list): List of (longitude, latitude) coordinates defining the area
|
|
598
|
-
|
|
599
|
-
Returns:
|
|
600
|
-
tuple: (timezone string with UTC offset, central meridian longitude string)
|
|
601
|
-
|
|
602
|
-
Example:
|
|
603
|
-
>>> coords = [(139.65, 35.67), (139.66, 35.67),
|
|
604
|
-
... (139.66, 35.68), (139.65, 35.68)]
|
|
605
|
-
>>> tz, meridian = get_timezone_info(coords)
|
|
606
|
-
>>> print(f"Timezone: {tz}, Meridian: {meridian}") # e.g., "UTC+09:00, 135.00000"
|
|
607
|
-
"""
|
|
608
|
-
# Calculate center point of rectangle
|
|
609
|
-
longitudes = [coord[0] for coord in rectangle_coords]
|
|
610
|
-
latitudes = [coord[1] for coord in rectangle_coords]
|
|
611
|
-
center_lon = sum(longitudes) / len(longitudes)
|
|
612
|
-
center_lat = sum(latitudes) / len(latitudes)
|
|
613
|
-
|
|
614
|
-
# Find timezone at center coordinates
|
|
615
|
-
tf = TimezoneFinder()
|
|
616
|
-
timezone_str = tf.timezone_at(lng=center_lon, lat=center_lat)
|
|
617
|
-
|
|
618
|
-
if timezone_str:
|
|
619
|
-
# Get current time in local timezone to calculate offset
|
|
620
|
-
timezone = pytz.timezone(timezone_str)
|
|
621
|
-
now = datetime.now(timezone)
|
|
622
|
-
offset_seconds = now.utcoffset().total_seconds()
|
|
623
|
-
offset_hours = offset_seconds / 3600
|
|
624
|
-
|
|
625
|
-
# Format timezone offset and calculate central meridian
|
|
626
|
-
utc_offset = f"UTC{offset_hours:+.2f}"
|
|
627
|
-
timezone_longitude = offset_hours * 15 # Each hour offset = 15 degrees longitude
|
|
628
|
-
timezone_longitude_str = f"{timezone_longitude:.5f}"
|
|
629
|
-
|
|
630
|
-
return utc_offset, timezone_longitude_str
|
|
631
|
-
else:
|
|
632
|
-
# Return fallback values if timezone cannot be determined
|
|
633
|
-
logger.warning("Timezone not found for the given location, using UTC+00:00")
|
|
634
|
-
return "UTC+00:00", "0.00000"
|
|
635
|
-
|
|
636
|
-
def validate_polygon_coordinates(geometry):
|
|
637
|
-
"""
|
|
638
|
-
Validate and ensure proper closure of polygon coordinate rings.
|
|
639
|
-
Performs validation and correction of GeoJSON polygon geometries according to
|
|
640
|
-
the GeoJSON specification requirements.
|
|
641
|
-
|
|
642
|
-
Validation checks:
|
|
643
|
-
1. Geometry type (Polygon or MultiPolygon)
|
|
644
|
-
2. Ring closure (first point equals last point)
|
|
645
|
-
3. Minimum number of points (4, including closure)
|
|
646
|
-
|
|
647
|
-
Args:
|
|
648
|
-
geometry (dict): GeoJSON geometry object with 'type' and 'coordinates' properties
|
|
649
|
-
|
|
650
|
-
Returns:
|
|
651
|
-
bool: True if polygon coordinates are valid or were successfully corrected,
|
|
652
|
-
False if validation failed
|
|
653
|
-
|
|
654
|
-
Example:
|
|
655
|
-
>>> geom = {
|
|
656
|
-
... "type": "Polygon",
|
|
657
|
-
... "coordinates": [[[0,0], [1,0], [1,1], [0,1]]] # Not closed
|
|
658
|
-
... }
|
|
659
|
-
>>> if validate_polygon_coordinates(geom):
|
|
660
|
-
... print("Polygon is valid") # Will close the ring automatically
|
|
661
|
-
"""
|
|
662
|
-
if geometry['type'] == 'Polygon':
|
|
663
|
-
for ring in geometry['coordinates']:
|
|
664
|
-
# Ensure polygon is closed by checking/adding first point at end
|
|
665
|
-
if ring[0] != ring[-1]:
|
|
666
|
-
ring.append(ring[0]) # Close the ring
|
|
667
|
-
# Check minimum points needed for valid polygon (3 points + closing point)
|
|
668
|
-
if len(ring) < 4:
|
|
669
|
-
return False
|
|
670
|
-
return True
|
|
671
|
-
elif geometry['type'] == 'MultiPolygon':
|
|
672
|
-
for polygon in geometry['coordinates']:
|
|
673
|
-
for ring in polygon:
|
|
674
|
-
if ring[0] != ring[-1]:
|
|
675
|
-
ring.append(ring[0]) # Close the ring
|
|
676
|
-
if len(ring) < 4:
|
|
677
|
-
return False
|
|
678
|
-
return True
|
|
679
|
-
else:
|
|
680
|
-
return False
|
|
681
|
-
|
|
682
|
-
def create_building_polygons(filtered_buildings):
|
|
683
|
-
"""
|
|
684
|
-
Create building polygons with properties from filtered GeoJSON features.
|
|
685
|
-
Processes a list of GeoJSON building features to create Shapely polygons
|
|
686
|
-
with associated height and other properties, while also building a spatial index.
|
|
687
|
-
|
|
688
|
-
Processing steps:
|
|
689
|
-
1. Extract and validate coordinates
|
|
690
|
-
2. Create Shapely polygons
|
|
691
|
-
3. Process building properties (height, levels, etc.)
|
|
692
|
-
4. Build spatial index for efficient querying
|
|
693
|
-
|
|
694
|
-
Height calculation rules:
|
|
695
|
-
- Use explicit height if available
|
|
696
|
-
- Calculate from levels * floor_height if height not available
|
|
697
|
-
- Calculate from floors * floor_height if levels not available
|
|
698
|
-
- Use NaN if no height information available
|
|
699
|
-
|
|
700
|
-
Args:
|
|
701
|
-
filtered_buildings (list): List of GeoJSON building features with properties
|
|
702
|
-
|
|
703
|
-
Returns:
|
|
704
|
-
tuple: (
|
|
705
|
-
list of tuples (polygon, height, min_height, is_inner, feature_id),
|
|
706
|
-
rtree spatial index for the polygons
|
|
707
|
-
)
|
|
708
|
-
|
|
709
|
-
Example:
|
|
710
|
-
>>> buildings = [
|
|
711
|
-
... {
|
|
712
|
-
... "type": "Feature",
|
|
713
|
-
... "geometry": {"type": "Polygon", "coordinates": [...]},
|
|
714
|
-
... "properties": {"height": 30, "levels": 10}
|
|
715
|
-
... },
|
|
716
|
-
... # ... more buildings ...
|
|
717
|
-
... ]
|
|
718
|
-
>>> polygons, spatial_idx = create_building_polygons(buildings)
|
|
719
|
-
"""
|
|
720
|
-
building_polygons = []
|
|
721
|
-
idx = index.Index()
|
|
722
|
-
valid_count = 0
|
|
723
|
-
count = 0
|
|
724
|
-
|
|
725
|
-
# Find highest existing ID to avoid duplicates
|
|
726
|
-
id_list = []
|
|
727
|
-
for i, building in enumerate(filtered_buildings):
|
|
728
|
-
if building['properties'].get('id') is not None:
|
|
729
|
-
id_list.append(building['properties']['id'])
|
|
730
|
-
if len(id_list) > 0:
|
|
731
|
-
id_count = max(id_list)+1
|
|
732
|
-
else:
|
|
733
|
-
id_count = 1
|
|
734
|
-
|
|
735
|
-
for building in filtered_buildings:
|
|
736
|
-
try:
|
|
737
|
-
# Handle potential nested coordinate tuples
|
|
738
|
-
coords = building['geometry']['coordinates'][0]
|
|
739
|
-
# Flatten coordinates if they're nested tuples
|
|
740
|
-
if isinstance(coords[0], tuple):
|
|
741
|
-
coords = [list(c) for c in coords]
|
|
742
|
-
elif isinstance(coords[0][0], tuple):
|
|
743
|
-
coords = [list(c[0]) for c in coords]
|
|
744
|
-
|
|
745
|
-
# Create polygon from coordinates
|
|
746
|
-
polygon = Polygon(coords)
|
|
747
|
-
|
|
748
|
-
# Skip invalid geometries
|
|
749
|
-
if not polygon.is_valid:
|
|
750
|
-
logger.warning("Skipping invalid polygon geometry")
|
|
751
|
-
continue
|
|
752
|
-
|
|
753
|
-
height = building['properties'].get('height')
|
|
754
|
-
levels = building['properties'].get('levels')
|
|
755
|
-
floors = building['properties'].get('num_floors')
|
|
756
|
-
min_height = building['properties'].get('min_height')
|
|
757
|
-
min_level = building['properties'].get('min_level')
|
|
758
|
-
min_floor = building['properties'].get('min_floor')
|
|
759
|
-
|
|
760
|
-
if (height is None) or (height<=0):
|
|
761
|
-
if levels is not None:
|
|
762
|
-
height = floor_height * levels
|
|
763
|
-
elif floors is not None:
|
|
764
|
-
height = floor_height * floors
|
|
765
|
-
else:
|
|
766
|
-
count += 1
|
|
767
|
-
height = np.nan
|
|
768
|
-
|
|
769
|
-
if (min_height is None) or (min_height<=0):
|
|
770
|
-
if min_level is not None:
|
|
771
|
-
min_height = floor_height * float(min_level)
|
|
772
|
-
elif min_floor is not None:
|
|
773
|
-
min_height = floor_height * float(min_floor)
|
|
774
|
-
else:
|
|
775
|
-
min_height = 0
|
|
776
|
-
|
|
777
|
-
if building['properties'].get('id') is not None:
|
|
778
|
-
feature_id = building['properties']['id']
|
|
779
|
-
else:
|
|
780
|
-
feature_id = id_count
|
|
781
|
-
id_count += 1
|
|
782
|
-
|
|
783
|
-
if building['properties'].get('is_inner') is not None:
|
|
784
|
-
is_inner = building['properties']['is_inner']
|
|
785
|
-
else:
|
|
786
|
-
is_inner = False
|
|
787
|
-
|
|
788
|
-
building_polygons.append((polygon, height, min_height, is_inner, feature_id))
|
|
789
|
-
idx.insert(valid_count, polygon.bounds)
|
|
790
|
-
valid_count += 1
|
|
791
|
-
|
|
792
|
-
except Exception as e:
|
|
793
|
-
logger.warning("Skipping invalid building geometry: %s", e)
|
|
794
|
-
continue
|
|
795
|
-
|
|
796
|
-
return building_polygons, idx
|
|
797
|
-
|
|
798
|
-
def get_country_name(lon, lat):
|
|
799
|
-
"""
|
|
800
|
-
Get country name from coordinates using reverse geocoding.
|
|
801
|
-
Uses a local database for fast reverse geocoding to country level,
|
|
802
|
-
then converts the country code to full name using pycountry.
|
|
803
|
-
|
|
804
|
-
Args:
|
|
805
|
-
lon (float): Longitude in decimal degrees
|
|
806
|
-
lat (float): Latitude in decimal degrees
|
|
807
|
-
|
|
808
|
-
Returns:
|
|
809
|
-
str: Full country name or None if lookup fails
|
|
810
|
-
|
|
811
|
-
Example:
|
|
812
|
-
>>> country = get_country_name(139.6503, 35.6762)
|
|
813
|
-
>>> print(f"Country: {country}") # "Japan"
|
|
814
|
-
"""
|
|
815
|
-
# Use reverse geocoder to get country code
|
|
816
|
-
results = rg.search((lat, lon))
|
|
817
|
-
country_code = results[0]['cc']
|
|
818
|
-
|
|
819
|
-
# Convert country code to full name using pycountry
|
|
820
|
-
country = pycountry.countries.get(alpha_2=country_code)
|
|
821
|
-
|
|
822
|
-
if country:
|
|
823
|
-
return country.name
|
|
824
|
-
else:
|
|
1
|
+
"""
|
|
2
|
+
Utility functions for geographic operations and coordinate transformations.
|
|
3
|
+
|
|
4
|
+
This module provides various utility functions for working with geographic data,
|
|
5
|
+
including coordinate transformations, distance calculations, geocoding, and building
|
|
6
|
+
polygon processing. It supports operations such as:
|
|
7
|
+
|
|
8
|
+
- Tile coordinate calculations and quadkey conversions
|
|
9
|
+
- Geographic distance calculations (Haversine and geodetic)
|
|
10
|
+
- Coordinate system transformations
|
|
11
|
+
- Polygon and GeoDataFrame operations
|
|
12
|
+
- Raster file processing and merging
|
|
13
|
+
- Geocoding and reverse geocoding
|
|
14
|
+
- Timezone and location information retrieval
|
|
15
|
+
- Building polygon validation and processing
|
|
16
|
+
|
|
17
|
+
The module uses several external libraries for geographic operations:
|
|
18
|
+
- pyproj: For coordinate transformations and geodetic calculations
|
|
19
|
+
- geopandas: For handling geographic data frames
|
|
20
|
+
- rasterio: For raster file operations
|
|
21
|
+
- shapely: For geometric operations
|
|
22
|
+
- geopy: For geocoding services
|
|
23
|
+
- timezonefinder: For timezone lookups
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
# Standard library imports
|
|
27
|
+
import os
|
|
28
|
+
import math
|
|
29
|
+
from math import radians, sin, cos, sqrt, atan2
|
|
30
|
+
from datetime import datetime
|
|
31
|
+
|
|
32
|
+
# Third-party geographic processing libraries
|
|
33
|
+
import numpy as np
|
|
34
|
+
from pyproj import Geod, Transformer
|
|
35
|
+
import geopandas as gpd
|
|
36
|
+
import rasterio
|
|
37
|
+
from rasterio.merge import merge
|
|
38
|
+
from rasterio.warp import transform_bounds
|
|
39
|
+
from rasterio.mask import mask
|
|
40
|
+
from shapely.geometry import Polygon, box
|
|
41
|
+
from fiona.crs import from_epsg
|
|
42
|
+
from rtree import index
|
|
43
|
+
|
|
44
|
+
# Geocoding and location services
|
|
45
|
+
from geopy.geocoders import Nominatim
|
|
46
|
+
from geopy.exc import GeocoderTimedOut, GeocoderServiceError, GeocoderInsufficientPrivileges
|
|
47
|
+
from geopy.extra.rate_limiter import RateLimiter
|
|
48
|
+
import reverse_geocoder as rg
|
|
49
|
+
import pycountry
|
|
50
|
+
|
|
51
|
+
# Timezone handling
|
|
52
|
+
from timezonefinder import TimezoneFinder
|
|
53
|
+
import pytz
|
|
54
|
+
|
|
55
|
+
# Suppress rasterio warnings for non-georeferenced files
|
|
56
|
+
import warnings
|
|
57
|
+
warnings.filterwarnings("ignore", category=rasterio.errors.NotGeoreferencedWarning)
|
|
58
|
+
|
|
59
|
+
# Global constants
|
|
60
|
+
floor_height = 2.5 # Standard floor height in meters used for building height calculations
|
|
61
|
+
|
|
62
|
+
# Package logging
|
|
63
|
+
from ..utils.logging import get_logger
|
|
64
|
+
logger = get_logger(__name__)
|
|
65
|
+
|
|
66
|
+
# Build a compliant Nominatim user agent once and reuse it
|
|
67
|
+
try:
|
|
68
|
+
# Prefer package metadata if available
|
|
69
|
+
from voxcity import __version__ as _vox_version, __email__ as _vox_email
|
|
70
|
+
except Exception:
|
|
71
|
+
_vox_version, _vox_email = "dev", "contact@voxcity.local"
|
|
72
|
+
|
|
73
|
+
_ENV_UA = os.environ.get("VOXCITY_NOMINATIM_UA", "").strip()
|
|
74
|
+
_DEFAULT_UA = f"voxcity/{_vox_version} (+https://github.com/kunifujiwara/voxcity; contact: {_vox_email})"
|
|
75
|
+
_NOMINATIM_USER_AGENT = _ENV_UA or _DEFAULT_UA
|
|
76
|
+
|
|
77
|
+
def _create_nominatim_geolocator() -> Nominatim:
|
|
78
|
+
"""
|
|
79
|
+
Create a Nominatim geolocator with a compliant identifying user agent.
|
|
80
|
+
The user agent can be overridden via the environment variable
|
|
81
|
+
VOXCITY_NOMINATIM_UA.
|
|
82
|
+
"""
|
|
83
|
+
return Nominatim(user_agent=_NOMINATIM_USER_AGENT)
|
|
84
|
+
|
|
85
|
+
def tile_from_lat_lon(lat, lon, level_of_detail):
|
|
86
|
+
"""
|
|
87
|
+
Convert latitude/longitude coordinates to tile coordinates at a given zoom level.
|
|
88
|
+
Uses the Web Mercator projection (EPSG:3857) commonly used in web mapping.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
lat (float): Latitude in degrees (-90 to 90)
|
|
92
|
+
lon (float): Longitude in degrees (-180 to 180)
|
|
93
|
+
level_of_detail (int): Zoom level (0-23, where 0 is the entire world)
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
tuple: (tile_x, tile_y) tile coordinates in the global tile grid
|
|
97
|
+
|
|
98
|
+
Example:
|
|
99
|
+
>>> tile_x, tile_y = tile_from_lat_lon(35.6762, 139.6503, 12) # Tokyo at zoom 12
|
|
100
|
+
"""
|
|
101
|
+
# Convert latitude to radians and calculate sine
|
|
102
|
+
sin_lat = math.sin(lat * math.pi / 180)
|
|
103
|
+
|
|
104
|
+
# Convert longitude to normalized x coordinate (0-1)
|
|
105
|
+
x = (lon + 180) / 360
|
|
106
|
+
|
|
107
|
+
# Convert latitude to y coordinate using Mercator projection formula
|
|
108
|
+
y = 0.5 - math.log((1 + sin_lat) / (1 - sin_lat)) / (4 * math.pi)
|
|
109
|
+
|
|
110
|
+
# Calculate map size in pixels at this zoom level (256 * 2^zoom)
|
|
111
|
+
map_size = 256 << level_of_detail
|
|
112
|
+
|
|
113
|
+
# Convert x,y to tile coordinates
|
|
114
|
+
tile_x = int(x * map_size / 256)
|
|
115
|
+
tile_y = int(y * map_size / 256)
|
|
116
|
+
return tile_x, tile_y
|
|
117
|
+
|
|
118
|
+
def quadkey_to_tile(quadkey):
|
|
119
|
+
"""
|
|
120
|
+
Convert a quadkey string to tile coordinates.
|
|
121
|
+
A quadkey is a string of digits (0-3) that identifies a tile at a certain zoom level.
|
|
122
|
+
Each digit in the quadkey represents a tile at a zoom level, with each subsequent digit
|
|
123
|
+
representing a more detailed zoom level.
|
|
124
|
+
|
|
125
|
+
The quadkey numbering scheme:
|
|
126
|
+
- 0: Top-left quadrant
|
|
127
|
+
- 1: Top-right quadrant
|
|
128
|
+
- 2: Bottom-left quadrant
|
|
129
|
+
- 3: Bottom-right quadrant
|
|
130
|
+
|
|
131
|
+
Args:
|
|
132
|
+
quadkey (str): Quadkey string (e.g., "120" for zoom level 3)
|
|
133
|
+
|
|
134
|
+
Returns:
|
|
135
|
+
tuple: (tile_x, tile_y, level_of_detail) tile coordinates and zoom level
|
|
136
|
+
|
|
137
|
+
Example:
|
|
138
|
+
>>> x, y, zoom = quadkey_to_tile("120") # Returns coordinates at zoom level 3
|
|
139
|
+
"""
|
|
140
|
+
tile_x = tile_y = 0
|
|
141
|
+
level_of_detail = len(quadkey)
|
|
142
|
+
|
|
143
|
+
# Process each character in quadkey
|
|
144
|
+
for i in range(level_of_detail):
|
|
145
|
+
bit = level_of_detail - i - 1
|
|
146
|
+
mask = 1 << bit
|
|
147
|
+
|
|
148
|
+
# Quadkey digit to binary:
|
|
149
|
+
# 0 = neither x nor y bit set
|
|
150
|
+
# 1 = x bit set
|
|
151
|
+
# 2 = y bit set
|
|
152
|
+
# 3 = both x and y bits set
|
|
153
|
+
if quadkey[i] == '1':
|
|
154
|
+
tile_x |= mask
|
|
155
|
+
elif quadkey[i] == '2':
|
|
156
|
+
tile_y |= mask
|
|
157
|
+
elif quadkey[i] == '3':
|
|
158
|
+
tile_x |= mask
|
|
159
|
+
tile_y |= mask
|
|
160
|
+
return tile_x, tile_y, level_of_detail
|
|
161
|
+
|
|
162
|
+
def initialize_geod():
|
|
163
|
+
"""
|
|
164
|
+
Initialize a Geod object for geodetic calculations using WGS84 ellipsoid.
|
|
165
|
+
The WGS84 ellipsoid (EPSG:4326) is the standard reference system used by GPS
|
|
166
|
+
and most modern mapping applications.
|
|
167
|
+
|
|
168
|
+
The Geod object provides methods for:
|
|
169
|
+
- Forward geodetic calculations (direct)
|
|
170
|
+
- Inverse geodetic calculations (inverse)
|
|
171
|
+
- Area calculations
|
|
172
|
+
- Line length calculations
|
|
173
|
+
|
|
174
|
+
Returns:
|
|
175
|
+
Geod: Initialized Geod object for WGS84 calculations
|
|
176
|
+
|
|
177
|
+
Example:
|
|
178
|
+
>>> geod = initialize_geod()
|
|
179
|
+
>>> fwd_az, back_az, dist = geod.inv(lon1, lat1, lon2, lat2)
|
|
180
|
+
"""
|
|
181
|
+
return Geod(ellps='WGS84')
|
|
182
|
+
|
|
183
|
+
def calculate_distance(geod, lon1, lat1, lon2, lat2):
|
|
184
|
+
"""
|
|
185
|
+
Calculate geodetic distance between two points on the Earth's surface.
|
|
186
|
+
Uses inverse geodetic computation to find the shortest distance along the ellipsoid,
|
|
187
|
+
which is more accurate than great circle (spherical) calculations.
|
|
188
|
+
|
|
189
|
+
Args:
|
|
190
|
+
geod (Geod): Geod object for calculations, initialized with WGS84
|
|
191
|
+
lon1, lat1 (float): Coordinates of first point in decimal degrees
|
|
192
|
+
lon2, lat2 (float): Coordinates of second point in decimal degrees
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
float: Distance in meters between the two points along the ellipsoid
|
|
196
|
+
|
|
197
|
+
Example:
|
|
198
|
+
>>> geod = initialize_geod()
|
|
199
|
+
>>> distance = calculate_distance(geod, 139.6503, 35.6762,
|
|
200
|
+
... -74.0060, 40.7128) # Tokyo to NYC
|
|
201
|
+
"""
|
|
202
|
+
# inv() returns forward azimuth, back azimuth, and distance
|
|
203
|
+
_, _, dist = geod.inv(lon1, lat1, lon2, lat2)
|
|
204
|
+
return dist
|
|
205
|
+
|
|
206
|
+
def normalize_to_one_meter(vector, distance_in_meters):
|
|
207
|
+
"""
|
|
208
|
+
Normalize a vector to represent one meter in geographic space.
|
|
209
|
+
Useful for creating unit vectors in geographic calculations, particularly
|
|
210
|
+
when working with distance-based operations or scaling geographic features.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
vector (numpy.ndarray): Vector to normalize, typically a direction vector
|
|
214
|
+
distance_in_meters (float): Current distance in meters that the vector represents
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
numpy.ndarray: Normalized vector where magnitude represents 1 meter
|
|
218
|
+
|
|
219
|
+
Example:
|
|
220
|
+
>>> direction = np.array([3.0, 4.0]) # Vector of length 5
|
|
221
|
+
>>> unit_meter = normalize_to_one_meter(direction, 5.0)
|
|
222
|
+
"""
|
|
223
|
+
return vector * (1 / distance_in_meters)
|
|
224
|
+
|
|
225
|
+
def setup_transformer(from_crs, to_crs):
|
|
226
|
+
"""
|
|
227
|
+
Set up a coordinate transformer between two Coordinate Reference Systems (CRS).
|
|
228
|
+
The always_xy=True parameter ensures consistent handling of coordinate order
|
|
229
|
+
by always using (x,y) or (longitude,latitude) order regardless of CRS definition.
|
|
230
|
+
|
|
231
|
+
Common CRS codes:
|
|
232
|
+
- EPSG:4326 - WGS84 (latitude/longitude)
|
|
233
|
+
- EPSG:3857 - Web Mercator
|
|
234
|
+
- EPSG:2263 - NY State Plane
|
|
235
|
+
|
|
236
|
+
Args:
|
|
237
|
+
from_crs: Source coordinate reference system (EPSG code, proj4 string, or CRS dict)
|
|
238
|
+
to_crs: Target coordinate reference system (EPSG code, proj4 string, or CRS dict)
|
|
239
|
+
|
|
240
|
+
Returns:
|
|
241
|
+
Transformer: Initialized transformer object for coordinate conversion
|
|
242
|
+
|
|
243
|
+
Example:
|
|
244
|
+
>>> transformer = setup_transformer("EPSG:4326", "EPSG:3857")
|
|
245
|
+
>>> x, y = transformer.transform(longitude, latitude)
|
|
246
|
+
"""
|
|
247
|
+
return Transformer.from_crs(from_crs, to_crs, always_xy=True)
|
|
248
|
+
|
|
249
|
+
def transform_coords(transformer, lon, lat):
|
|
250
|
+
"""
|
|
251
|
+
Transform coordinates using provided transformer with error handling.
|
|
252
|
+
Includes validation for infinite values that may result from invalid transformations
|
|
253
|
+
or coordinates outside the valid range for the target CRS.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
transformer (Transformer): Coordinate transformer from setup_transformer()
|
|
257
|
+
lon, lat (float): Input coordinates in the source CRS
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
tuple: (x, y) transformed coordinates in the target CRS, or (None, None) if transformation fails
|
|
261
|
+
|
|
262
|
+
Example:
|
|
263
|
+
>>> transformer = setup_transformer("EPSG:4326", "EPSG:3857")
|
|
264
|
+
>>> x, y = transform_coords(transformer, -74.0060, 40.7128) # NYC coordinates
|
|
265
|
+
>>> if x is not None:
|
|
266
|
+
... print(f"Transformed coordinates: ({x}, {y})")
|
|
267
|
+
"""
|
|
268
|
+
try:
|
|
269
|
+
x, y = transformer.transform(lon, lat)
|
|
270
|
+
if np.isinf(x) or np.isinf(y):
|
|
271
|
+
logger.warning("Transformation resulted in inf values for coordinates: %s, %s", lon, lat)
|
|
272
|
+
return x, y
|
|
273
|
+
except Exception as e:
|
|
274
|
+
logger.error("Error transforming coordinates %s, %s: %s", lon, lat, e)
|
|
275
|
+
return None, None
|
|
276
|
+
|
|
277
|
+
def create_polygon(vertices):
|
|
278
|
+
"""
|
|
279
|
+
Create a Shapely polygon from a list of vertices.
|
|
280
|
+
Input vertices must be in (longitude, latitude) format as required by Shapely.
|
|
281
|
+
The polygon will be automatically closed if the first and last vertices don't match.
|
|
282
|
+
|
|
283
|
+
Args:
|
|
284
|
+
vertices (list): List of (longitude, latitude) coordinate pairs forming the polygon.
|
|
285
|
+
The coordinates should be in counter-clockwise order for exterior rings
|
|
286
|
+
and clockwise order for interior rings (holes).
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
Polygon: Shapely polygon object that can be used for spatial operations
|
|
290
|
+
|
|
291
|
+
Example:
|
|
292
|
+
>>> vertices = [(0, 0), (1, 0), (1, 1), (0, 1)] # Square
|
|
293
|
+
>>> polygon = create_polygon(vertices)
|
|
294
|
+
>>> print(f"Polygon area: {polygon.area}")
|
|
295
|
+
"""
|
|
296
|
+
return Polygon(vertices)
|
|
297
|
+
|
|
298
|
+
def create_geodataframe(polygon, crs=4326):
|
|
299
|
+
"""
|
|
300
|
+
Create a GeoDataFrame from a Shapely polygon.
|
|
301
|
+
Default CRS is WGS84 (EPSG:4326) for geographic coordinates.
|
|
302
|
+
The GeoDataFrame provides additional functionality for spatial operations,
|
|
303
|
+
data analysis, and export to various geographic formats.
|
|
304
|
+
|
|
305
|
+
Args:
|
|
306
|
+
polygon (Polygon): Shapely polygon object to convert
|
|
307
|
+
crs (int): Coordinate reference system EPSG code (default: 4326 for WGS84)
|
|
308
|
+
|
|
309
|
+
Returns:
|
|
310
|
+
GeoDataFrame: GeoDataFrame containing the polygon with specified CRS
|
|
311
|
+
|
|
312
|
+
Example:
|
|
313
|
+
>>> vertices = [(0, 0), (1, 0), (1, 1), (0, 1)]
|
|
314
|
+
>>> polygon = create_polygon(vertices)
|
|
315
|
+
>>> gdf = create_geodataframe(polygon)
|
|
316
|
+
>>> gdf.to_file("polygon.geojson", driver="GeoJSON")
|
|
317
|
+
"""
|
|
318
|
+
return gpd.GeoDataFrame({'geometry': [polygon]}, crs=from_epsg(crs))
|
|
319
|
+
|
|
320
|
+
def haversine_distance(lon1, lat1, lon2, lat2):
|
|
321
|
+
"""
|
|
322
|
+
Calculate great-circle distance between two points using Haversine formula.
|
|
323
|
+
This is an approximation that treats the Earth as a perfect sphere.
|
|
324
|
+
|
|
325
|
+
Args:
|
|
326
|
+
lon1, lat1 (float): Coordinates of first point
|
|
327
|
+
lon2, lat2 (float): Coordinates of second point
|
|
328
|
+
|
|
329
|
+
Returns:
|
|
330
|
+
float: Distance in kilometers
|
|
331
|
+
"""
|
|
332
|
+
R = 6371 # Earth's radius in kilometers
|
|
333
|
+
|
|
334
|
+
# Convert all coordinates to radians
|
|
335
|
+
lat1, lon1, lat2, lon2 = map(radians, [lat1, lon1, lat2, lon2])
|
|
336
|
+
|
|
337
|
+
# Calculate differences
|
|
338
|
+
dlat = lat2 - lat1
|
|
339
|
+
dlon = lon2 - lon1
|
|
340
|
+
|
|
341
|
+
# Haversine formula
|
|
342
|
+
a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlon/2)**2
|
|
343
|
+
c = 2 * atan2(sqrt(a), sqrt(1-a))
|
|
344
|
+
return R * c
|
|
345
|
+
|
|
346
|
+
def get_raster_bbox(raster_path):
|
|
347
|
+
"""
|
|
348
|
+
Get the bounding box of a raster file in its native coordinate system.
|
|
349
|
+
Returns a rectangular polygon representing the spatial extent of the raster,
|
|
350
|
+
which can be used for spatial queries and intersection tests.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
raster_path (str): Path to the raster file (GeoTIFF, IMG, etc.)
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
box: Shapely box representing the raster bounds in the raster's CRS
|
|
357
|
+
|
|
358
|
+
Example:
|
|
359
|
+
>>> bbox = get_raster_bbox("elevation.tif")
|
|
360
|
+
>>> print(f"Raster extent: {bbox.bounds}") # (minx, miny, maxx, maxy)
|
|
361
|
+
"""
|
|
362
|
+
with rasterio.open(raster_path) as src:
|
|
363
|
+
bounds = src.bounds
|
|
364
|
+
return box(bounds.left, bounds.bottom, bounds.right, bounds.top)
|
|
365
|
+
|
|
366
|
+
def raster_intersects_polygon(raster_path, polygon):
|
|
367
|
+
"""
|
|
368
|
+
Check if a raster file's extent intersects with a given polygon.
|
|
369
|
+
Automatically handles coordinate system transformations by converting
|
|
370
|
+
the raster bounds to WGS84 (EPSG:4326) if needed before the intersection test.
|
|
371
|
+
|
|
372
|
+
Args:
|
|
373
|
+
raster_path (str): Path to the raster file to check
|
|
374
|
+
polygon (Polygon): Shapely polygon to test intersection with (in WGS84)
|
|
375
|
+
|
|
376
|
+
Returns:
|
|
377
|
+
bool: True if raster intersects or contains the polygon, False otherwise
|
|
378
|
+
|
|
379
|
+
Example:
|
|
380
|
+
>>> aoi = create_polygon([(lon1, lat1), (lon2, lat2), ...]) # Area of interest
|
|
381
|
+
>>> if raster_intersects_polygon("dem.tif", aoi):
|
|
382
|
+
... print("Raster covers the area of interest")
|
|
383
|
+
"""
|
|
384
|
+
with rasterio.open(raster_path) as src:
|
|
385
|
+
bounds = src.bounds
|
|
386
|
+
# Transform bounds to WGS84 if raster is in different CRS
|
|
387
|
+
if src.crs.to_epsg() != 4326:
|
|
388
|
+
bounds = transform_bounds(src.crs, 'EPSG:4326', *bounds)
|
|
389
|
+
raster_bbox = box(*bounds)
|
|
390
|
+
intersects = raster_bbox.intersects(polygon) or polygon.intersects(raster_bbox)
|
|
391
|
+
return intersects
|
|
392
|
+
|
|
393
|
+
def save_raster(input_path, output_path):
|
|
394
|
+
"""
|
|
395
|
+
Create a copy of a raster file at a new location.
|
|
396
|
+
Performs a direct file copy without any transformation or modification,
|
|
397
|
+
preserving all metadata, georeferencing, and pixel values.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
input_path (str): Source raster file path
|
|
401
|
+
output_path (str): Destination path for the copied raster
|
|
402
|
+
|
|
403
|
+
Example:
|
|
404
|
+
>>> save_raster("original.tif", "backup/copy.tif")
|
|
405
|
+
>>> print("Copied original file to: backup/copy.tif")
|
|
406
|
+
"""
|
|
407
|
+
import shutil
|
|
408
|
+
shutil.copy(input_path, output_path)
|
|
409
|
+
logger.info("Copied original file to: %s", output_path)
|
|
410
|
+
|
|
411
|
+
def merge_geotiffs(geotiff_files, output_dir):
|
|
412
|
+
"""
|
|
413
|
+
Merge multiple GeoTIFF files into a single mosaic.
|
|
414
|
+
Handles edge matching and overlapping areas between adjacent rasters.
|
|
415
|
+
The output will have the same coordinate system and data type as the input files.
|
|
416
|
+
|
|
417
|
+
Important considerations:
|
|
418
|
+
- All input files should have the same coordinate system
|
|
419
|
+
- All input files should have the same data type
|
|
420
|
+
- Overlapping areas are handled by taking the first value encountered
|
|
421
|
+
|
|
422
|
+
Args:
|
|
423
|
+
geotiff_files (list): List of paths to GeoTIFF files to merge
|
|
424
|
+
output_dir (str): Directory where the merged output will be saved
|
|
425
|
+
|
|
426
|
+
Example:
|
|
427
|
+
>>> files = ["tile1.tif", "tile2.tif", "tile3.tif"]
|
|
428
|
+
>>> merge_geotiffs(files, "output_directory")
|
|
429
|
+
>>> print("Merged output saved to: output_directory/lulc.tif")
|
|
430
|
+
"""
|
|
431
|
+
if not geotiff_files:
|
|
432
|
+
return
|
|
433
|
+
|
|
434
|
+
# Open all valid GeoTIFF files
|
|
435
|
+
src_files_to_mosaic = [rasterio.open(file) for file in geotiff_files if os.path.exists(file)]
|
|
436
|
+
|
|
437
|
+
if src_files_to_mosaic:
|
|
438
|
+
try:
|
|
439
|
+
# Merge rasters into a single mosaic and get output transform
|
|
440
|
+
mosaic, out_trans = merge(src_files_to_mosaic)
|
|
441
|
+
|
|
442
|
+
# Copy metadata from first raster and update for merged output
|
|
443
|
+
out_meta = src_files_to_mosaic[0].meta.copy()
|
|
444
|
+
out_meta.update({
|
|
445
|
+
"driver": "GTiff",
|
|
446
|
+
"height": mosaic.shape[1],
|
|
447
|
+
"width": mosaic.shape[2],
|
|
448
|
+
"transform": out_trans
|
|
449
|
+
})
|
|
450
|
+
|
|
451
|
+
# Save merged raster to output file
|
|
452
|
+
merged_path = os.path.join(output_dir, "lulc.tif")
|
|
453
|
+
with rasterio.open(merged_path, "w", **out_meta) as dest:
|
|
454
|
+
dest.write(mosaic)
|
|
455
|
+
|
|
456
|
+
logger.info("Merged output saved to: %s", merged_path)
|
|
457
|
+
except Exception as e:
|
|
458
|
+
logger.error("Error merging files: %s", e)
|
|
459
|
+
else:
|
|
460
|
+
logger.info("No valid files to merge.")
|
|
461
|
+
|
|
462
|
+
# Clean up by closing all opened files
|
|
463
|
+
for src in src_files_to_mosaic:
|
|
464
|
+
src.close()
|
|
465
|
+
|
|
466
|
+
def convert_format_lat_lon(input_coords):
|
|
467
|
+
"""
|
|
468
|
+
Convert coordinate format and close polygon.
|
|
469
|
+
Input coordinates are already in [lon, lat] format.
|
|
470
|
+
|
|
471
|
+
Args:
|
|
472
|
+
input_coords (list): List of [lon, lat] coordinates
|
|
473
|
+
|
|
474
|
+
Returns:
|
|
475
|
+
list: List of [lon, lat] coordinates with first point repeated at end
|
|
476
|
+
"""
|
|
477
|
+
# Create list with coordinates in same order
|
|
478
|
+
output_coords = input_coords.copy()
|
|
479
|
+
# Close polygon by repeating first point at end
|
|
480
|
+
output_coords.append(output_coords[0])
|
|
481
|
+
return output_coords
|
|
482
|
+
|
|
483
|
+
def get_coordinates_from_cityname(place_name):
|
|
484
|
+
"""
|
|
485
|
+
Geocode a city name to get its coordinates using OpenStreetMap's Nominatim service.
|
|
486
|
+
Includes rate limiting and error handling to comply with Nominatim's usage policy.
|
|
487
|
+
|
|
488
|
+
Note:
|
|
489
|
+
- Results may vary based on the specificity of the place name
|
|
490
|
+
- For better results, include country or state information
|
|
491
|
+
- Service has usage limits and may timeout
|
|
492
|
+
|
|
493
|
+
Args:
|
|
494
|
+
place_name (str): Name of the city to geocode (e.g., "Tokyo, Japan")
|
|
495
|
+
|
|
496
|
+
Returns:
|
|
497
|
+
tuple: (latitude, longitude) coordinates or None if geocoding fails
|
|
498
|
+
|
|
499
|
+
Example:
|
|
500
|
+
>>> coords = get_coordinates_from_cityname("Paris, France")
|
|
501
|
+
>>> if coords:
|
|
502
|
+
... lat, lon = coords
|
|
503
|
+
... print(f"Paris coordinates: {lat}, {lon}")
|
|
504
|
+
"""
|
|
505
|
+
# Initialize geocoder with compliant user agent
|
|
506
|
+
geolocator = _create_nominatim_geolocator()
|
|
507
|
+
geocode_once = RateLimiter(geolocator.geocode, min_delay_seconds=1.0, max_retries=0)
|
|
508
|
+
|
|
509
|
+
try:
|
|
510
|
+
# Attempt to geocode the place name (single try; no retries on 403)
|
|
511
|
+
location = geocode_once(place_name, exactly_one=True, timeout=10)
|
|
512
|
+
|
|
513
|
+
if location:
|
|
514
|
+
return (location.latitude, location.longitude)
|
|
515
|
+
else:
|
|
516
|
+
return None
|
|
517
|
+
except GeocoderInsufficientPrivileges:
|
|
518
|
+
logger.warning("Nominatim blocked the request (HTTP 403). Please set a proper user agent and avoid bulk requests.")
|
|
519
|
+
return None
|
|
520
|
+
except (GeocoderTimedOut, GeocoderServiceError):
|
|
521
|
+
logger.error("Geocoding service timed out or encountered an error for %s", place_name)
|
|
522
|
+
return None
|
|
523
|
+
|
|
524
|
+
def get_city_country_name_from_rectangle(coordinates):
|
|
525
|
+
"""
|
|
526
|
+
Get the city and country name for a location defined by a rectangle.
|
|
527
|
+
Uses reverse geocoding to find the nearest named place to the rectangle's center.
|
|
528
|
+
|
|
529
|
+
The function:
|
|
530
|
+
1. Calculates the center point of the rectangle
|
|
531
|
+
2. Performs reverse geocoding with rate limiting
|
|
532
|
+
3. Extracts city and country information from the result
|
|
533
|
+
|
|
534
|
+
Args:
|
|
535
|
+
coordinates (list): List of (longitude, latitude) coordinates defining the rectangle
|
|
536
|
+
|
|
537
|
+
Returns:
|
|
538
|
+
str: String in format "city/ country" or fallback value if lookup fails
|
|
539
|
+
|
|
540
|
+
Example:
|
|
541
|
+
>>> coords = [(139.65, 35.67), (139.66, 35.67),
|
|
542
|
+
... (139.66, 35.68), (139.65, 35.68)]
|
|
543
|
+
>>> location = get_city_country_name_from_rectangle(coords)
|
|
544
|
+
>>> print(f"Location: {location}") # e.g., "Shibuya/ Japan"
|
|
545
|
+
"""
|
|
546
|
+
# Calculate center point of rectangle
|
|
547
|
+
longitudes = [coord[0] for coord in coordinates]
|
|
548
|
+
latitudes = [coord[1] for coord in coordinates]
|
|
549
|
+
center_lon = sum(longitudes) / len(longitudes)
|
|
550
|
+
center_lat = sum(latitudes) / len(latitudes)
|
|
551
|
+
center_coord = (center_lat, center_lon)
|
|
552
|
+
|
|
553
|
+
# Initialize geocoder with compliant user agent and conservative rate limit (1 req/sec)
|
|
554
|
+
geolocator = _create_nominatim_geolocator()
|
|
555
|
+
reverse_once = RateLimiter(geolocator.reverse, min_delay_seconds=1.0, max_retries=0)
|
|
556
|
+
|
|
557
|
+
try:
|
|
558
|
+
# Attempt reverse geocoding of center coordinates (single try; no retries on 403)
|
|
559
|
+
location = reverse_once(center_coord, language='en', exactly_one=True, timeout=10)
|
|
560
|
+
if location:
|
|
561
|
+
address = location.raw['address']
|
|
562
|
+
# Try multiple address fields to find city name, falling back to county if needed
|
|
563
|
+
city = address.get('city', '') or address.get('town', '') or address.get('village', '') or address.get('county', '')
|
|
564
|
+
country = address.get('country', '')
|
|
565
|
+
return f"{city}/ {country}"
|
|
566
|
+
else:
|
|
567
|
+
logger.info("Reverse geocoding location not found for %s", center_coord)
|
|
568
|
+
return "Unknown Location/ Unknown Country"
|
|
569
|
+
except GeocoderInsufficientPrivileges:
|
|
570
|
+
# Fallback to offline reverse_geocoder at coarse resolution
|
|
571
|
+
try:
|
|
572
|
+
results = rg.search((center_lat, center_lon))
|
|
573
|
+
name = results[0].get('name') or ''
|
|
574
|
+
country = get_country_name(center_lon, center_lat) or ''
|
|
575
|
+
if name or country:
|
|
576
|
+
return f"{name}/ {country}".strip()
|
|
577
|
+
except Exception:
|
|
578
|
+
pass
|
|
579
|
+
logger.warning("Nominatim blocked the request (HTTP 403). Falling back to offline coarse reverse geocoding.")
|
|
580
|
+
return "Unknown Location/ Unknown Country"
|
|
581
|
+
except (GeocoderTimedOut, GeocoderServiceError) as e:
|
|
582
|
+
logger.error("Error retrieving location for %s: %s", center_coord, e)
|
|
583
|
+
return "Unknown Location/ Unknown Country"
|
|
584
|
+
|
|
585
|
+
def get_timezone_info(rectangle_coords):
|
|
586
|
+
"""
|
|
587
|
+
Get timezone and central meridian information for a location.
|
|
588
|
+
Uses the rectangle's center point to determine the local timezone and
|
|
589
|
+
calculates the central meridian based on the UTC offset.
|
|
590
|
+
|
|
591
|
+
The function provides:
|
|
592
|
+
1. Local timezone identifier (e.g., "America/New_York")
|
|
593
|
+
2. UTC offset (e.g., "UTC-04:00")
|
|
594
|
+
3. Central meridian longitude for the timezone
|
|
595
|
+
|
|
596
|
+
Args:
|
|
597
|
+
rectangle_coords (list): List of (longitude, latitude) coordinates defining the area
|
|
598
|
+
|
|
599
|
+
Returns:
|
|
600
|
+
tuple: (timezone string with UTC offset, central meridian longitude string)
|
|
601
|
+
|
|
602
|
+
Example:
|
|
603
|
+
>>> coords = [(139.65, 35.67), (139.66, 35.67),
|
|
604
|
+
... (139.66, 35.68), (139.65, 35.68)]
|
|
605
|
+
>>> tz, meridian = get_timezone_info(coords)
|
|
606
|
+
>>> print(f"Timezone: {tz}, Meridian: {meridian}") # e.g., "UTC+09:00, 135.00000"
|
|
607
|
+
"""
|
|
608
|
+
# Calculate center point of rectangle
|
|
609
|
+
longitudes = [coord[0] for coord in rectangle_coords]
|
|
610
|
+
latitudes = [coord[1] for coord in rectangle_coords]
|
|
611
|
+
center_lon = sum(longitudes) / len(longitudes)
|
|
612
|
+
center_lat = sum(latitudes) / len(latitudes)
|
|
613
|
+
|
|
614
|
+
# Find timezone at center coordinates
|
|
615
|
+
tf = TimezoneFinder()
|
|
616
|
+
timezone_str = tf.timezone_at(lng=center_lon, lat=center_lat)
|
|
617
|
+
|
|
618
|
+
if timezone_str:
|
|
619
|
+
# Get current time in local timezone to calculate offset
|
|
620
|
+
timezone = pytz.timezone(timezone_str)
|
|
621
|
+
now = datetime.now(timezone)
|
|
622
|
+
offset_seconds = now.utcoffset().total_seconds()
|
|
623
|
+
offset_hours = offset_seconds / 3600
|
|
624
|
+
|
|
625
|
+
# Format timezone offset and calculate central meridian
|
|
626
|
+
utc_offset = f"UTC{offset_hours:+.2f}"
|
|
627
|
+
timezone_longitude = offset_hours * 15 # Each hour offset = 15 degrees longitude
|
|
628
|
+
timezone_longitude_str = f"{timezone_longitude:.5f}"
|
|
629
|
+
|
|
630
|
+
return utc_offset, timezone_longitude_str
|
|
631
|
+
else:
|
|
632
|
+
# Return fallback values if timezone cannot be determined
|
|
633
|
+
logger.warning("Timezone not found for the given location, using UTC+00:00")
|
|
634
|
+
return "UTC+00:00", "0.00000"
|
|
635
|
+
|
|
636
|
+
def validate_polygon_coordinates(geometry):
|
|
637
|
+
"""
|
|
638
|
+
Validate and ensure proper closure of polygon coordinate rings.
|
|
639
|
+
Performs validation and correction of GeoJSON polygon geometries according to
|
|
640
|
+
the GeoJSON specification requirements.
|
|
641
|
+
|
|
642
|
+
Validation checks:
|
|
643
|
+
1. Geometry type (Polygon or MultiPolygon)
|
|
644
|
+
2. Ring closure (first point equals last point)
|
|
645
|
+
3. Minimum number of points (4, including closure)
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
geometry (dict): GeoJSON geometry object with 'type' and 'coordinates' properties
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
bool: True if polygon coordinates are valid or were successfully corrected,
|
|
652
|
+
False if validation failed
|
|
653
|
+
|
|
654
|
+
Example:
|
|
655
|
+
>>> geom = {
|
|
656
|
+
... "type": "Polygon",
|
|
657
|
+
... "coordinates": [[[0,0], [1,0], [1,1], [0,1]]] # Not closed
|
|
658
|
+
... }
|
|
659
|
+
>>> if validate_polygon_coordinates(geom):
|
|
660
|
+
... print("Polygon is valid") # Will close the ring automatically
|
|
661
|
+
"""
|
|
662
|
+
if geometry['type'] == 'Polygon':
|
|
663
|
+
for ring in geometry['coordinates']:
|
|
664
|
+
# Ensure polygon is closed by checking/adding first point at end
|
|
665
|
+
if ring[0] != ring[-1]:
|
|
666
|
+
ring.append(ring[0]) # Close the ring
|
|
667
|
+
# Check minimum points needed for valid polygon (3 points + closing point)
|
|
668
|
+
if len(ring) < 4:
|
|
669
|
+
return False
|
|
670
|
+
return True
|
|
671
|
+
elif geometry['type'] == 'MultiPolygon':
|
|
672
|
+
for polygon in geometry['coordinates']:
|
|
673
|
+
for ring in polygon:
|
|
674
|
+
if ring[0] != ring[-1]:
|
|
675
|
+
ring.append(ring[0]) # Close the ring
|
|
676
|
+
if len(ring) < 4:
|
|
677
|
+
return False
|
|
678
|
+
return True
|
|
679
|
+
else:
|
|
680
|
+
return False
|
|
681
|
+
|
|
682
|
+
def create_building_polygons(filtered_buildings):
|
|
683
|
+
"""
|
|
684
|
+
Create building polygons with properties from filtered GeoJSON features.
|
|
685
|
+
Processes a list of GeoJSON building features to create Shapely polygons
|
|
686
|
+
with associated height and other properties, while also building a spatial index.
|
|
687
|
+
|
|
688
|
+
Processing steps:
|
|
689
|
+
1. Extract and validate coordinates
|
|
690
|
+
2. Create Shapely polygons
|
|
691
|
+
3. Process building properties (height, levels, etc.)
|
|
692
|
+
4. Build spatial index for efficient querying
|
|
693
|
+
|
|
694
|
+
Height calculation rules:
|
|
695
|
+
- Use explicit height if available
|
|
696
|
+
- Calculate from levels * floor_height if height not available
|
|
697
|
+
- Calculate from floors * floor_height if levels not available
|
|
698
|
+
- Use NaN if no height information available
|
|
699
|
+
|
|
700
|
+
Args:
|
|
701
|
+
filtered_buildings (list): List of GeoJSON building features with properties
|
|
702
|
+
|
|
703
|
+
Returns:
|
|
704
|
+
tuple: (
|
|
705
|
+
list of tuples (polygon, height, min_height, is_inner, feature_id),
|
|
706
|
+
rtree spatial index for the polygons
|
|
707
|
+
)
|
|
708
|
+
|
|
709
|
+
Example:
|
|
710
|
+
>>> buildings = [
|
|
711
|
+
... {
|
|
712
|
+
... "type": "Feature",
|
|
713
|
+
... "geometry": {"type": "Polygon", "coordinates": [...]},
|
|
714
|
+
... "properties": {"height": 30, "levels": 10}
|
|
715
|
+
... },
|
|
716
|
+
... # ... more buildings ...
|
|
717
|
+
... ]
|
|
718
|
+
>>> polygons, spatial_idx = create_building_polygons(buildings)
|
|
719
|
+
"""
|
|
720
|
+
building_polygons = []
|
|
721
|
+
idx = index.Index()
|
|
722
|
+
valid_count = 0
|
|
723
|
+
count = 0
|
|
724
|
+
|
|
725
|
+
# Find highest existing ID to avoid duplicates
|
|
726
|
+
id_list = []
|
|
727
|
+
for i, building in enumerate(filtered_buildings):
|
|
728
|
+
if building['properties'].get('id') is not None:
|
|
729
|
+
id_list.append(building['properties']['id'])
|
|
730
|
+
if len(id_list) > 0:
|
|
731
|
+
id_count = max(id_list)+1
|
|
732
|
+
else:
|
|
733
|
+
id_count = 1
|
|
734
|
+
|
|
735
|
+
for building in filtered_buildings:
|
|
736
|
+
try:
|
|
737
|
+
# Handle potential nested coordinate tuples
|
|
738
|
+
coords = building['geometry']['coordinates'][0]
|
|
739
|
+
# Flatten coordinates if they're nested tuples
|
|
740
|
+
if isinstance(coords[0], tuple):
|
|
741
|
+
coords = [list(c) for c in coords]
|
|
742
|
+
elif isinstance(coords[0][0], tuple):
|
|
743
|
+
coords = [list(c[0]) for c in coords]
|
|
744
|
+
|
|
745
|
+
# Create polygon from coordinates
|
|
746
|
+
polygon = Polygon(coords)
|
|
747
|
+
|
|
748
|
+
# Skip invalid geometries
|
|
749
|
+
if not polygon.is_valid:
|
|
750
|
+
logger.warning("Skipping invalid polygon geometry")
|
|
751
|
+
continue
|
|
752
|
+
|
|
753
|
+
height = building['properties'].get('height')
|
|
754
|
+
levels = building['properties'].get('levels')
|
|
755
|
+
floors = building['properties'].get('num_floors')
|
|
756
|
+
min_height = building['properties'].get('min_height')
|
|
757
|
+
min_level = building['properties'].get('min_level')
|
|
758
|
+
min_floor = building['properties'].get('min_floor')
|
|
759
|
+
|
|
760
|
+
if (height is None) or (height<=0):
|
|
761
|
+
if levels is not None:
|
|
762
|
+
height = floor_height * levels
|
|
763
|
+
elif floors is not None:
|
|
764
|
+
height = floor_height * floors
|
|
765
|
+
else:
|
|
766
|
+
count += 1
|
|
767
|
+
height = np.nan
|
|
768
|
+
|
|
769
|
+
if (min_height is None) or (min_height<=0):
|
|
770
|
+
if min_level is not None:
|
|
771
|
+
min_height = floor_height * float(min_level)
|
|
772
|
+
elif min_floor is not None:
|
|
773
|
+
min_height = floor_height * float(min_floor)
|
|
774
|
+
else:
|
|
775
|
+
min_height = 0
|
|
776
|
+
|
|
777
|
+
if building['properties'].get('id') is not None:
|
|
778
|
+
feature_id = building['properties']['id']
|
|
779
|
+
else:
|
|
780
|
+
feature_id = id_count
|
|
781
|
+
id_count += 1
|
|
782
|
+
|
|
783
|
+
if building['properties'].get('is_inner') is not None:
|
|
784
|
+
is_inner = building['properties']['is_inner']
|
|
785
|
+
else:
|
|
786
|
+
is_inner = False
|
|
787
|
+
|
|
788
|
+
building_polygons.append((polygon, height, min_height, is_inner, feature_id))
|
|
789
|
+
idx.insert(valid_count, polygon.bounds)
|
|
790
|
+
valid_count += 1
|
|
791
|
+
|
|
792
|
+
except Exception as e:
|
|
793
|
+
logger.warning("Skipping invalid building geometry: %s", e)
|
|
794
|
+
continue
|
|
795
|
+
|
|
796
|
+
return building_polygons, idx
|
|
797
|
+
|
|
798
|
+
def get_country_name(lon, lat):
|
|
799
|
+
"""
|
|
800
|
+
Get country name from coordinates using reverse geocoding.
|
|
801
|
+
Uses a local database for fast reverse geocoding to country level,
|
|
802
|
+
then converts the country code to full name using pycountry.
|
|
803
|
+
|
|
804
|
+
Args:
|
|
805
|
+
lon (float): Longitude in decimal degrees
|
|
806
|
+
lat (float): Latitude in decimal degrees
|
|
807
|
+
|
|
808
|
+
Returns:
|
|
809
|
+
str: Full country name or None if lookup fails
|
|
810
|
+
|
|
811
|
+
Example:
|
|
812
|
+
>>> country = get_country_name(139.6503, 35.6762)
|
|
813
|
+
>>> print(f"Country: {country}") # "Japan"
|
|
814
|
+
"""
|
|
815
|
+
# Use reverse geocoder to get country code
|
|
816
|
+
results = rg.search((lat, lon))
|
|
817
|
+
country_code = results[0]['cc']
|
|
818
|
+
|
|
819
|
+
# Convert country code to full name using pycountry
|
|
820
|
+
country = pycountry.countries.get(alpha_2=country_code)
|
|
821
|
+
|
|
822
|
+
if country:
|
|
823
|
+
return country.name
|
|
824
|
+
else:
|
|
825
825
|
return None
|