voxcity 1.0.2__py3-none-any.whl → 1.0.13__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- voxcity/downloader/ocean.py +559 -0
- voxcity/generator/api.py +6 -0
- voxcity/generator/grids.py +45 -32
- voxcity/generator/pipeline.py +327 -27
- voxcity/geoprocessor/draw.py +14 -8
- voxcity/geoprocessor/raster/__init__.py +2 -0
- voxcity/geoprocessor/raster/core.py +31 -0
- voxcity/geoprocessor/raster/landcover.py +173 -49
- voxcity/geoprocessor/raster/raster.py +1 -1
- voxcity/models.py +2 -0
- voxcity/simulator_gpu/__init__.py +115 -0
- voxcity/simulator_gpu/common/__init__.py +9 -0
- voxcity/simulator_gpu/common/geometry.py +11 -0
- voxcity/simulator_gpu/core.py +322 -0
- voxcity/simulator_gpu/domain.py +262 -0
- voxcity/simulator_gpu/environment.yml +11 -0
- voxcity/simulator_gpu/init_taichi.py +154 -0
- voxcity/simulator_gpu/integration.py +15 -0
- voxcity/simulator_gpu/kernels.py +56 -0
- voxcity/simulator_gpu/radiation.py +28 -0
- voxcity/simulator_gpu/raytracing.py +623 -0
- voxcity/simulator_gpu/sky.py +9 -0
- voxcity/simulator_gpu/solar/__init__.py +178 -0
- voxcity/simulator_gpu/solar/core.py +66 -0
- voxcity/simulator_gpu/solar/csf.py +1249 -0
- voxcity/simulator_gpu/solar/domain.py +561 -0
- voxcity/simulator_gpu/solar/epw.py +421 -0
- voxcity/simulator_gpu/solar/integration.py +2953 -0
- voxcity/simulator_gpu/solar/radiation.py +3019 -0
- voxcity/simulator_gpu/solar/raytracing.py +686 -0
- voxcity/simulator_gpu/solar/reflection.py +533 -0
- voxcity/simulator_gpu/solar/sky.py +907 -0
- voxcity/simulator_gpu/solar/solar.py +337 -0
- voxcity/simulator_gpu/solar/svf.py +446 -0
- voxcity/simulator_gpu/solar/volumetric.py +1151 -0
- voxcity/simulator_gpu/solar/voxcity.py +2953 -0
- voxcity/simulator_gpu/temporal.py +13 -0
- voxcity/simulator_gpu/utils.py +25 -0
- voxcity/simulator_gpu/view.py +32 -0
- voxcity/simulator_gpu/visibility/__init__.py +109 -0
- voxcity/simulator_gpu/visibility/geometry.py +278 -0
- voxcity/simulator_gpu/visibility/integration.py +808 -0
- voxcity/simulator_gpu/visibility/landmark.py +753 -0
- voxcity/simulator_gpu/visibility/view.py +944 -0
- voxcity/visualizer/renderer.py +2 -1
- {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/METADATA +16 -53
- {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/RECORD +50 -15
- {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
- {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/licenses/LICENSE +0 -0
|
@@ -0,0 +1,559 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Ocean detection using OSM coastlines via Overpass API.
|
|
3
|
+
|
|
4
|
+
OSM handles oceans by the "absence of land" principle:
|
|
5
|
+
1. The renderer starts with a blue canvas
|
|
6
|
+
2. Land polygons (derived from natural=coastline) are drawn on top
|
|
7
|
+
3. Anything not covered by land is ocean
|
|
8
|
+
|
|
9
|
+
This module queries coastlines from Overpass API and determines land/ocean
|
|
10
|
+
based on the coastline orientation rule: land is on the LEFT of the coastline.
|
|
11
|
+
|
|
12
|
+
For areas without coastlines, we check if the point is in the ocean using
|
|
13
|
+
a simple heuristic based on nearby land features.
|
|
14
|
+
"""
|
|
15
|
+
import os
|
|
16
|
+
import tempfile
|
|
17
|
+
import hashlib
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import List, Tuple, Optional
|
|
20
|
+
import requests
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
# Cache directory for ocean detection results (optional)
|
|
24
|
+
CACHE_DIR = Path(tempfile.gettempdir()) / "voxcity_ocean_cache"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def get_land_polygon_for_area(rectangle_vertices: List[Tuple[float, float]], use_cache: bool = False):
|
|
28
|
+
"""
|
|
29
|
+
Get the land polygon for a given area using OSM coastlines.
|
|
30
|
+
|
|
31
|
+
This is the main entry point for ocean detection. It queries coastlines
|
|
32
|
+
from Overpass API and builds a polygon representing land areas.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
rectangle_vertices: List of (lon, lat) tuples defining the area
|
|
36
|
+
use_cache: Whether to use disk cache (default False)
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Shapely Polygon/MultiPolygon representing land, or None if no coastlines found
|
|
40
|
+
"""
|
|
41
|
+
min_lon = min(v[0] for v in rectangle_vertices)
|
|
42
|
+
max_lon = max(v[0] for v in rectangle_vertices)
|
|
43
|
+
min_lat = min(v[1] for v in rectangle_vertices)
|
|
44
|
+
max_lat = max(v[1] for v in rectangle_vertices)
|
|
45
|
+
|
|
46
|
+
bbox = (min_lon, min_lat, max_lon, max_lat)
|
|
47
|
+
|
|
48
|
+
# Query coastlines from Overpass API
|
|
49
|
+
overpass_data = query_coastlines_from_overpass(min_lat, min_lon, max_lat, max_lon)
|
|
50
|
+
|
|
51
|
+
# Count coastlines
|
|
52
|
+
coastline_count = sum(1 for e in overpass_data.get('elements', [])
|
|
53
|
+
if e.get('type') == 'way' and e.get('tags', {}).get('natural') == 'coastline')
|
|
54
|
+
|
|
55
|
+
if coastline_count == 0:
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
# Build land polygon from coastlines
|
|
59
|
+
land_polygon = build_coastline_polygons(overpass_data, bbox)
|
|
60
|
+
|
|
61
|
+
return land_polygon
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def get_cache_path(rectangle_vertices: List[Tuple[float, float]], grid_shape: Tuple[int, int]) -> Path:
|
|
65
|
+
"""Generate a cache filename based on the bounding box and grid shape."""
|
|
66
|
+
min_lon = min(v[0] for v in rectangle_vertices)
|
|
67
|
+
max_lon = max(v[0] for v in rectangle_vertices)
|
|
68
|
+
min_lat = min(v[1] for v in rectangle_vertices)
|
|
69
|
+
max_lat = max(v[1] for v in rectangle_vertices)
|
|
70
|
+
|
|
71
|
+
bbox_str = f"{min_lon:.6f}_{min_lat:.6f}_{max_lon:.6f}_{max_lat:.6f}_{grid_shape[0]}_{grid_shape[1]}"
|
|
72
|
+
cache_hash = hashlib.md5(bbox_str.encode()).hexdigest()[:12]
|
|
73
|
+
|
|
74
|
+
CACHE_DIR.mkdir(parents=True, exist_ok=True)
|
|
75
|
+
return CACHE_DIR / f"ocean_mask_{cache_hash}.npy"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def query_coastlines_from_overpass(
|
|
79
|
+
min_lat: float, min_lon: float, max_lat: float, max_lon: float,
|
|
80
|
+
buffer_deg: float = 0.1
|
|
81
|
+
) -> List[dict]:
|
|
82
|
+
"""
|
|
83
|
+
Query coastline ways from Overpass API.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
min_lat, min_lon, max_lat, max_lon: Bounding box
|
|
87
|
+
buffer_deg: Buffer around bbox to catch coastlines that might affect the area
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
List of coastline way dictionaries with node coordinates
|
|
91
|
+
"""
|
|
92
|
+
# Expand bbox slightly to catch nearby coastlines
|
|
93
|
+
query_min_lat = min_lat - buffer_deg
|
|
94
|
+
query_max_lat = max_lat + buffer_deg
|
|
95
|
+
query_min_lon = min_lon - buffer_deg
|
|
96
|
+
query_max_lon = max_lon + buffer_deg
|
|
97
|
+
|
|
98
|
+
query = f"""
|
|
99
|
+
[out:json][timeout:30];
|
|
100
|
+
(
|
|
101
|
+
way["natural"="coastline"]({query_min_lat},{query_min_lon},{query_max_lat},{query_max_lon});
|
|
102
|
+
);
|
|
103
|
+
out body;
|
|
104
|
+
>;
|
|
105
|
+
out skel qt;
|
|
106
|
+
"""
|
|
107
|
+
|
|
108
|
+
overpass_endpoints = [
|
|
109
|
+
"https://overpass-api.de/api/interpreter",
|
|
110
|
+
"https://overpass.kumi.systems/api/interpreter",
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
headers = {"User-Agent": "voxcity/1.0 (https://github.com/kunifujiwara/voxcity)"}
|
|
114
|
+
|
|
115
|
+
for endpoint in overpass_endpoints:
|
|
116
|
+
try:
|
|
117
|
+
response = requests.get(endpoint, params={'data': query}, headers=headers, timeout=30)
|
|
118
|
+
if response.status_code == 200:
|
|
119
|
+
return response.json()
|
|
120
|
+
except Exception:
|
|
121
|
+
continue
|
|
122
|
+
|
|
123
|
+
return {'elements': []}
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def build_coastline_polygons(overpass_data: dict, bbox: Tuple[float, float, float, float]):
|
|
127
|
+
"""
|
|
128
|
+
Build land polygons by splitting bbox with coastlines.
|
|
129
|
+
|
|
130
|
+
Algorithm:
|
|
131
|
+
1. Merge bbox boundary with all clipped coastlines to form a network
|
|
132
|
+
2. Use polygonize to create all possible polygons
|
|
133
|
+
3. For each polygon, determine if it's land by checking the coastline direction
|
|
134
|
+
(land is on the LEFT of coastline when walking along it)
|
|
135
|
+
|
|
136
|
+
Returns:
|
|
137
|
+
Shapely polygon representing land area, or None if processing fails
|
|
138
|
+
"""
|
|
139
|
+
from shapely.geometry import LineString, Polygon, box, Point, MultiLineString
|
|
140
|
+
from shapely.ops import linemerge, unary_union, polygonize, split
|
|
141
|
+
import math
|
|
142
|
+
|
|
143
|
+
elements = overpass_data.get('elements', [])
|
|
144
|
+
|
|
145
|
+
# Build node lookup
|
|
146
|
+
nodes = {}
|
|
147
|
+
for elem in elements:
|
|
148
|
+
if elem.get('type') == 'node':
|
|
149
|
+
nodes[elem['id']] = (elem['lon'], elem['lat'])
|
|
150
|
+
|
|
151
|
+
# Build coastline linestrings preserving direction
|
|
152
|
+
coastlines = []
|
|
153
|
+
for elem in elements:
|
|
154
|
+
if elem.get('type') == 'way' and elem.get('tags', {}).get('natural') == 'coastline':
|
|
155
|
+
node_ids = elem.get('nodes', [])
|
|
156
|
+
coords = [nodes[nid] for nid in node_ids if nid in nodes]
|
|
157
|
+
if len(coords) >= 2:
|
|
158
|
+
coastlines.append(LineString(coords))
|
|
159
|
+
|
|
160
|
+
if not coastlines:
|
|
161
|
+
return None
|
|
162
|
+
|
|
163
|
+
min_lon, min_lat, max_lon, max_lat = bbox
|
|
164
|
+
bbox_polygon = box(min_lon, min_lat, max_lon, max_lat)
|
|
165
|
+
bbox_boundary = bbox_polygon.exterior
|
|
166
|
+
|
|
167
|
+
try:
|
|
168
|
+
# Clip each coastline to bbox and collect segments with their direction
|
|
169
|
+
clipped_segments = [] # List of (LineString, original_direction_preserved)
|
|
170
|
+
|
|
171
|
+
for coastline in coastlines:
|
|
172
|
+
if not coastline.intersects(bbox_polygon):
|
|
173
|
+
continue
|
|
174
|
+
|
|
175
|
+
# Get intersection with bbox
|
|
176
|
+
clipped = coastline.intersection(bbox_polygon)
|
|
177
|
+
if clipped.is_empty:
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
# Extract LineStrings
|
|
181
|
+
if clipped.geom_type == 'LineString':
|
|
182
|
+
if len(clipped.coords) >= 2:
|
|
183
|
+
clipped_segments.append(clipped)
|
|
184
|
+
elif clipped.geom_type == 'MultiLineString':
|
|
185
|
+
for line in clipped.geoms:
|
|
186
|
+
if len(line.coords) >= 2:
|
|
187
|
+
clipped_segments.append(line)
|
|
188
|
+
elif clipped.geom_type == 'GeometryCollection':
|
|
189
|
+
for geom in clipped.geoms:
|
|
190
|
+
if geom.geom_type == 'LineString' and len(geom.coords) >= 2:
|
|
191
|
+
clipped_segments.append(geom)
|
|
192
|
+
|
|
193
|
+
if not clipped_segments:
|
|
194
|
+
return None
|
|
195
|
+
|
|
196
|
+
# Combine bbox boundary with coastlines to create a line network
|
|
197
|
+
all_lines = [bbox_boundary] + clipped_segments
|
|
198
|
+
merged_lines = unary_union(all_lines)
|
|
199
|
+
|
|
200
|
+
# Polygonize the network
|
|
201
|
+
polygons = list(polygonize(merged_lines))
|
|
202
|
+
|
|
203
|
+
if not polygons:
|
|
204
|
+
return None
|
|
205
|
+
|
|
206
|
+
# For each polygon, determine if it's land or water
|
|
207
|
+
# A polygon is land if it's on the LEFT side of the coastlines that bound it
|
|
208
|
+
land_polygons = []
|
|
209
|
+
|
|
210
|
+
for poly in polygons:
|
|
211
|
+
if not poly.is_valid or poly.is_empty:
|
|
212
|
+
continue
|
|
213
|
+
|
|
214
|
+
# Check if this polygon is inside the bbox
|
|
215
|
+
if not poly.intersects(bbox_polygon):
|
|
216
|
+
continue
|
|
217
|
+
|
|
218
|
+
# Clip to bbox
|
|
219
|
+
poly_clipped = poly.intersection(bbox_polygon)
|
|
220
|
+
if poly_clipped.is_empty or poly_clipped.area < 1e-12:
|
|
221
|
+
continue
|
|
222
|
+
|
|
223
|
+
# Determine if this polygon is land
|
|
224
|
+
# Sample a point inside the polygon and check if it's on the land side
|
|
225
|
+
# of the nearby coastlines
|
|
226
|
+
is_land = is_polygon_land(poly_clipped, clipped_segments)
|
|
227
|
+
|
|
228
|
+
if is_land:
|
|
229
|
+
land_polygons.append(poly_clipped)
|
|
230
|
+
|
|
231
|
+
if land_polygons:
|
|
232
|
+
result = unary_union(land_polygons)
|
|
233
|
+
if not result.is_empty and result.area > 0:
|
|
234
|
+
return result
|
|
235
|
+
|
|
236
|
+
return None
|
|
237
|
+
|
|
238
|
+
except Exception as e:
|
|
239
|
+
return None
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def is_polygon_land(polygon, coastline_segments):
|
|
243
|
+
"""
|
|
244
|
+
Determine if a polygon is land based on coastline orientation.
|
|
245
|
+
|
|
246
|
+
OSM Rule: Land is on the LEFT of the coastline direction.
|
|
247
|
+
|
|
248
|
+
For a point to be on land, when standing on the nearest coastline
|
|
249
|
+
and facing the direction of the coastline, the point should be on your left.
|
|
250
|
+
|
|
251
|
+
This improved version checks against coastlines that actually BOUND the polygon,
|
|
252
|
+
not just the globally nearest coastline.
|
|
253
|
+
"""
|
|
254
|
+
from shapely.geometry import Point, LineString
|
|
255
|
+
import math
|
|
256
|
+
|
|
257
|
+
# Get a representative point inside the polygon
|
|
258
|
+
try:
|
|
259
|
+
test_point = polygon.representative_point()
|
|
260
|
+
except:
|
|
261
|
+
test_point = polygon.centroid
|
|
262
|
+
|
|
263
|
+
if test_point.is_empty:
|
|
264
|
+
return True # Default to land if we can't determine
|
|
265
|
+
|
|
266
|
+
px, py = test_point.x, test_point.y
|
|
267
|
+
|
|
268
|
+
# Find coastlines that actually touch/bound this polygon
|
|
269
|
+
bounding_coastlines = []
|
|
270
|
+
for coastline in coastline_segments:
|
|
271
|
+
# Check if coastline touches or is very close to polygon boundary
|
|
272
|
+
if polygon.exterior.distance(coastline) < 1e-8:
|
|
273
|
+
bounding_coastlines.append(coastline)
|
|
274
|
+
|
|
275
|
+
# If no bounding coastlines found, fall back to nearest
|
|
276
|
+
if not bounding_coastlines:
|
|
277
|
+
bounding_coastlines = coastline_segments
|
|
278
|
+
|
|
279
|
+
# Check all bounding coastlines and vote
|
|
280
|
+
votes_land = 0
|
|
281
|
+
votes_water = 0
|
|
282
|
+
|
|
283
|
+
for coastline in bounding_coastlines:
|
|
284
|
+
# Find the closest point on the coastline and determine the direction
|
|
285
|
+
coords = list(coastline.coords)
|
|
286
|
+
|
|
287
|
+
# Find which segment of the coastline is closest to the test point
|
|
288
|
+
closest_seg_idx = 0
|
|
289
|
+
closest_seg_dist = float('inf')
|
|
290
|
+
|
|
291
|
+
for i in range(len(coords) - 1):
|
|
292
|
+
seg = LineString([coords[i], coords[i + 1]])
|
|
293
|
+
seg_dist = seg.distance(test_point)
|
|
294
|
+
if seg_dist < closest_seg_dist:
|
|
295
|
+
closest_seg_dist = seg_dist
|
|
296
|
+
closest_seg_idx = i
|
|
297
|
+
|
|
298
|
+
# Get the direction vector of the closest segment
|
|
299
|
+
x1, y1 = coords[closest_seg_idx]
|
|
300
|
+
x2, y2 = coords[closest_seg_idx + 1]
|
|
301
|
+
|
|
302
|
+
# Direction vector of coastline
|
|
303
|
+
dx = x2 - x1
|
|
304
|
+
dy = y2 - y1
|
|
305
|
+
|
|
306
|
+
# Vector from segment start to test point
|
|
307
|
+
vx = px - x1
|
|
308
|
+
vy = py - y1
|
|
309
|
+
|
|
310
|
+
# Cross product: if positive, point is on the left; if negative, on the right
|
|
311
|
+
cross = dx * vy - dy * vx
|
|
312
|
+
|
|
313
|
+
if cross > 0:
|
|
314
|
+
votes_land += 1
|
|
315
|
+
else:
|
|
316
|
+
votes_water += 1
|
|
317
|
+
|
|
318
|
+
# Majority vote (default to land on tie)
|
|
319
|
+
return votes_land >= votes_water
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def check_if_area_is_ocean_via_land_features(
|
|
323
|
+
rectangle_vertices: List[Tuple[float, float]]
|
|
324
|
+
) -> bool:
|
|
325
|
+
"""
|
|
326
|
+
Quick check: if an area has buildings/roads/land-use features, it's not pure ocean.
|
|
327
|
+
|
|
328
|
+
Returns True if the area appears to be mostly ocean (few land features).
|
|
329
|
+
"""
|
|
330
|
+
min_lon = min(v[0] for v in rectangle_vertices)
|
|
331
|
+
max_lon = max(v[0] for v in rectangle_vertices)
|
|
332
|
+
min_lat = min(v[1] for v in rectangle_vertices)
|
|
333
|
+
max_lat = max(v[1] for v in rectangle_vertices)
|
|
334
|
+
|
|
335
|
+
# Quick query for any land features
|
|
336
|
+
query = f"""
|
|
337
|
+
[out:json][timeout:10];
|
|
338
|
+
(
|
|
339
|
+
way["building"]({min_lat},{min_lon},{max_lat},{max_lon});
|
|
340
|
+
way["highway"]({min_lat},{min_lon},{max_lat},{max_lon});
|
|
341
|
+
way["landuse"]({min_lat},{min_lon},{max_lat},{max_lon});
|
|
342
|
+
);
|
|
343
|
+
out count;
|
|
344
|
+
"""
|
|
345
|
+
|
|
346
|
+
try:
|
|
347
|
+
response = requests.get(
|
|
348
|
+
"https://overpass-api.de/api/interpreter",
|
|
349
|
+
params={'data': query},
|
|
350
|
+
headers={"User-Agent": "voxcity/1.0"},
|
|
351
|
+
timeout=15
|
|
352
|
+
)
|
|
353
|
+
if response.status_code == 200:
|
|
354
|
+
data = response.json()
|
|
355
|
+
# If there are substantial land features, it's not ocean
|
|
356
|
+
count = data.get('elements', [{}])[0].get('tags', {}).get('total', 0)
|
|
357
|
+
if isinstance(count, str):
|
|
358
|
+
count = int(count)
|
|
359
|
+
return count < 10 # Very few land features = likely ocean
|
|
360
|
+
except Exception:
|
|
361
|
+
pass
|
|
362
|
+
|
|
363
|
+
return False # Default to not ocean if we can't determine
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def get_land_mask_from_coastlines(
|
|
367
|
+
rectangle_vertices: List[Tuple[float, float]],
|
|
368
|
+
grid_shape: Tuple[int, int],
|
|
369
|
+
use_cache: bool = True
|
|
370
|
+
) -> np.ndarray:
|
|
371
|
+
"""
|
|
372
|
+
Create a boolean mask where True = land, False = ocean.
|
|
373
|
+
|
|
374
|
+
Uses Overpass API to query coastlines and determine land/ocean areas.
|
|
375
|
+
Much faster than downloading the full 600MB land polygons file.
|
|
376
|
+
|
|
377
|
+
Args:
|
|
378
|
+
rectangle_vertices: List of (lon, lat) tuples defining the area
|
|
379
|
+
grid_shape: (rows, cols) of the output grid
|
|
380
|
+
use_cache: Whether to cache the result
|
|
381
|
+
|
|
382
|
+
Returns:
|
|
383
|
+
np.ndarray: Boolean array where True = land, False = ocean
|
|
384
|
+
"""
|
|
385
|
+
from shapely.geometry import box, Point
|
|
386
|
+
from rasterio import features
|
|
387
|
+
from affine import Affine
|
|
388
|
+
|
|
389
|
+
cache_path = get_cache_path(rectangle_vertices, grid_shape)
|
|
390
|
+
|
|
391
|
+
# Check cache
|
|
392
|
+
if use_cache and cache_path.exists():
|
|
393
|
+
try:
|
|
394
|
+
cached = np.load(cache_path)
|
|
395
|
+
if cached.shape == grid_shape:
|
|
396
|
+
return cached
|
|
397
|
+
except Exception:
|
|
398
|
+
pass
|
|
399
|
+
|
|
400
|
+
min_lon = min(v[0] for v in rectangle_vertices)
|
|
401
|
+
max_lon = max(v[0] for v in rectangle_vertices)
|
|
402
|
+
min_lat = min(v[1] for v in rectangle_vertices)
|
|
403
|
+
max_lat = max(v[1] for v in rectangle_vertices)
|
|
404
|
+
|
|
405
|
+
rows, cols = grid_shape
|
|
406
|
+
|
|
407
|
+
# Query coastlines
|
|
408
|
+
print(" Querying coastlines from Overpass API...")
|
|
409
|
+
overpass_data = query_coastlines_from_overpass(min_lat, min_lon, max_lat, max_lon)
|
|
410
|
+
|
|
411
|
+
coastline_count = sum(1 for e in overpass_data.get('elements', [])
|
|
412
|
+
if e.get('type') == 'way' and e.get('tags', {}).get('natural') == 'coastline')
|
|
413
|
+
|
|
414
|
+
if coastline_count == 0:
|
|
415
|
+
# No coastlines in area - check if it's inland or open ocean
|
|
416
|
+
print(" No coastlines found in area.")
|
|
417
|
+
|
|
418
|
+
# Quick heuristic: if there are land features, assume all land
|
|
419
|
+
# If no land features, check if we're in the middle of the ocean
|
|
420
|
+
is_mostly_ocean = check_if_area_is_ocean_via_land_features(rectangle_vertices)
|
|
421
|
+
|
|
422
|
+
if is_mostly_ocean:
|
|
423
|
+
print(" Area appears to be open ocean (few land features).")
|
|
424
|
+
land_mask = np.zeros(grid_shape, dtype=bool)
|
|
425
|
+
else:
|
|
426
|
+
print(" Area appears to be inland (has land features).")
|
|
427
|
+
land_mask = np.ones(grid_shape, dtype=bool)
|
|
428
|
+
else:
|
|
429
|
+
print(f" Found {coastline_count} coastline segments.")
|
|
430
|
+
|
|
431
|
+
# Build land polygons from coastlines
|
|
432
|
+
land_polygon = build_coastline_polygons(
|
|
433
|
+
overpass_data,
|
|
434
|
+
(min_lon, min_lat, max_lon, max_lat)
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
if land_polygon is None:
|
|
438
|
+
# Coastline processing failed - use heuristic
|
|
439
|
+
print(" Could not build land polygon from coastlines, using land feature heuristic.")
|
|
440
|
+
is_mostly_ocean = check_if_area_is_ocean_via_land_features(rectangle_vertices)
|
|
441
|
+
land_mask = np.zeros(grid_shape, dtype=bool) if is_mostly_ocean else np.ones(grid_shape, dtype=bool)
|
|
442
|
+
else:
|
|
443
|
+
# Rasterize land polygon
|
|
444
|
+
pixel_width = (max_lon - min_lon) / cols
|
|
445
|
+
pixel_height = (max_lat - min_lat) / rows
|
|
446
|
+
transform = Affine(pixel_width, 0, min_lon, 0, -pixel_height, max_lat)
|
|
447
|
+
|
|
448
|
+
land_mask = np.zeros(grid_shape, dtype=np.uint8)
|
|
449
|
+
|
|
450
|
+
try:
|
|
451
|
+
if land_polygon.geom_type == 'Polygon':
|
|
452
|
+
geometries = [(land_polygon, 1)]
|
|
453
|
+
else: # MultiPolygon
|
|
454
|
+
geometries = [(geom, 1) for geom in land_polygon.geoms]
|
|
455
|
+
|
|
456
|
+
features.rasterize(
|
|
457
|
+
shapes=geometries,
|
|
458
|
+
out=land_mask,
|
|
459
|
+
transform=transform,
|
|
460
|
+
all_touched=False
|
|
461
|
+
)
|
|
462
|
+
land_mask = land_mask.astype(bool)
|
|
463
|
+
except Exception as e:
|
|
464
|
+
print(f" Warning: Rasterization failed: {e}")
|
|
465
|
+
land_mask = np.ones(grid_shape, dtype=bool)
|
|
466
|
+
|
|
467
|
+
# Cache the result
|
|
468
|
+
if use_cache:
|
|
469
|
+
try:
|
|
470
|
+
np.save(cache_path, land_mask)
|
|
471
|
+
except Exception:
|
|
472
|
+
pass
|
|
473
|
+
|
|
474
|
+
return land_mask
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# Alias for backward compatibility
|
|
478
|
+
get_land_mask_from_osm_land_polygons = get_land_mask_from_coastlines
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def get_ocean_class_for_source(source: str) -> str:
|
|
482
|
+
"""Get the appropriate ocean/water class name for a given land cover source."""
|
|
483
|
+
if source == "Urbanwatch":
|
|
484
|
+
return "Sea"
|
|
485
|
+
elif source in ["OpenStreetMap", "Standard", "OpenEarthMapJapan"]:
|
|
486
|
+
return "Water"
|
|
487
|
+
elif source == "ESA WorldCover":
|
|
488
|
+
return "Open water"
|
|
489
|
+
elif source == "Dynamic World V1":
|
|
490
|
+
return "Water"
|
|
491
|
+
elif source == "ESRI 10m Annual Land Cover":
|
|
492
|
+
return "Water"
|
|
493
|
+
else:
|
|
494
|
+
return "Water"
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
def apply_ocean_mask_to_grid(
|
|
498
|
+
grid: np.ndarray,
|
|
499
|
+
rectangle_vertices: List[Tuple[float, float]],
|
|
500
|
+
source: str = "OpenStreetMap",
|
|
501
|
+
ocean_class: Optional[str] = None
|
|
502
|
+
) -> np.ndarray:
|
|
503
|
+
"""
|
|
504
|
+
Apply ocean detection to an existing land cover grid.
|
|
505
|
+
|
|
506
|
+
Cells that are:
|
|
507
|
+
1. Currently set to the default class (e.g., 'Developed space')
|
|
508
|
+
2. Located in ocean areas (outside OSM land polygons)
|
|
509
|
+
|
|
510
|
+
Will be changed to the ocean/water class.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
grid: Land cover grid (2D array of class names)
|
|
514
|
+
rectangle_vertices: Area coordinates
|
|
515
|
+
source: Land cover source name
|
|
516
|
+
ocean_class: Override for ocean class name (auto-detected if None)
|
|
517
|
+
|
|
518
|
+
Returns:
|
|
519
|
+
Updated grid with ocean areas classified as water
|
|
520
|
+
"""
|
|
521
|
+
if ocean_class is None:
|
|
522
|
+
ocean_class = get_ocean_class_for_source(source)
|
|
523
|
+
|
|
524
|
+
# Get default class for this source
|
|
525
|
+
default_classes = {
|
|
526
|
+
"OpenStreetMap": "Developed space",
|
|
527
|
+
"Standard": "Developed space",
|
|
528
|
+
"OpenEarthMapJapan": "Developed space",
|
|
529
|
+
"Urbanwatch": "Unknown",
|
|
530
|
+
"ESA WorldCover": "Barren / sparse vegetation",
|
|
531
|
+
"Dynamic World V1": "Bare",
|
|
532
|
+
"ESRI 10m Annual Land Cover": "Bare Ground",
|
|
533
|
+
}
|
|
534
|
+
default_class = default_classes.get(source, "Developed space")
|
|
535
|
+
|
|
536
|
+
# Get land mask
|
|
537
|
+
land_mask = get_land_mask_from_osm_land_polygons(
|
|
538
|
+
rectangle_vertices,
|
|
539
|
+
grid.shape
|
|
540
|
+
)
|
|
541
|
+
|
|
542
|
+
# Flip land mask to match grid orientation (grid is flipped at the end of creation)
|
|
543
|
+
land_mask = np.flipud(land_mask)
|
|
544
|
+
|
|
545
|
+
# Apply ocean class to cells that are:
|
|
546
|
+
# 1. Not land (ocean according to OSM land polygons)
|
|
547
|
+
# 2. Currently classified as the default class
|
|
548
|
+
ocean_cells = ~land_mask & (grid == default_class)
|
|
549
|
+
|
|
550
|
+
grid_updated = grid.copy()
|
|
551
|
+
grid_updated[ocean_cells] = ocean_class
|
|
552
|
+
|
|
553
|
+
ocean_count = np.sum(ocean_cells)
|
|
554
|
+
if ocean_count > 0:
|
|
555
|
+
total_cells = grid.size
|
|
556
|
+
pct = 100 * ocean_count / total_cells
|
|
557
|
+
print(f" Ocean detection: {ocean_count:,} cells ({pct:.1f}%) classified as '{ocean_class}'")
|
|
558
|
+
|
|
559
|
+
return grid_updated
|
voxcity/generator/api.py
CHANGED
|
@@ -262,6 +262,8 @@ def get_voxcity(rectangle_vertices, meshsize, building_source=None, land_cover_s
|
|
|
262
262
|
building_gdf: Optional pre-loaded building GeoDataFrame
|
|
263
263
|
terrain_gdf: Optional pre-loaded terrain GeoDataFrame
|
|
264
264
|
**kwargs: Additional options for building, land cover, canopy, DEM, visualization, and I/O.
|
|
265
|
+
Performance options include:
|
|
266
|
+
- parallel_download: bool, if True downloads run concurrently (default: False)
|
|
265
267
|
I/O options include:
|
|
266
268
|
- output_dir: Directory for intermediate/downloaded data (default: "output")
|
|
267
269
|
- save_path: Full file path to save the VoxCity object (overrides output_dir default)
|
|
@@ -391,6 +393,9 @@ def get_voxcity(rectangle_vertices, meshsize, building_source=None, land_cover_s
|
|
|
391
393
|
visualize_options = {k: v for k, v in kwargs.items() if k in visualize_keys}
|
|
392
394
|
io_options = {k: v for k, v in kwargs.items() if k in io_keys}
|
|
393
395
|
|
|
396
|
+
# Parallel download mode
|
|
397
|
+
parallel_download = kwargs.get("parallel_download", False)
|
|
398
|
+
|
|
394
399
|
cfg = PipelineConfig(
|
|
395
400
|
rectangle_vertices=rectangle_vertices,
|
|
396
401
|
meshsize=float(meshsize),
|
|
@@ -404,6 +409,7 @@ def get_voxcity(rectangle_vertices, meshsize, building_source=None, land_cover_s
|
|
|
404
409
|
remove_perimeter_object=kwargs.get("remove_perimeter_object"),
|
|
405
410
|
mapvis=bool(kwargs.get("mapvis", False)),
|
|
406
411
|
gridvis=bool(kwargs.get("gridvis", True)),
|
|
412
|
+
parallel_download=parallel_download,
|
|
407
413
|
land_cover_options=land_cover_options,
|
|
408
414
|
building_options=building_options,
|
|
409
415
|
canopy_options=canopy_options,
|