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.
Files changed (50) hide show
  1. voxcity/downloader/ocean.py +559 -0
  2. voxcity/generator/api.py +6 -0
  3. voxcity/generator/grids.py +45 -32
  4. voxcity/generator/pipeline.py +327 -27
  5. voxcity/geoprocessor/draw.py +14 -8
  6. voxcity/geoprocessor/raster/__init__.py +2 -0
  7. voxcity/geoprocessor/raster/core.py +31 -0
  8. voxcity/geoprocessor/raster/landcover.py +173 -49
  9. voxcity/geoprocessor/raster/raster.py +1 -1
  10. voxcity/models.py +2 -0
  11. voxcity/simulator_gpu/__init__.py +115 -0
  12. voxcity/simulator_gpu/common/__init__.py +9 -0
  13. voxcity/simulator_gpu/common/geometry.py +11 -0
  14. voxcity/simulator_gpu/core.py +322 -0
  15. voxcity/simulator_gpu/domain.py +262 -0
  16. voxcity/simulator_gpu/environment.yml +11 -0
  17. voxcity/simulator_gpu/init_taichi.py +154 -0
  18. voxcity/simulator_gpu/integration.py +15 -0
  19. voxcity/simulator_gpu/kernels.py +56 -0
  20. voxcity/simulator_gpu/radiation.py +28 -0
  21. voxcity/simulator_gpu/raytracing.py +623 -0
  22. voxcity/simulator_gpu/sky.py +9 -0
  23. voxcity/simulator_gpu/solar/__init__.py +178 -0
  24. voxcity/simulator_gpu/solar/core.py +66 -0
  25. voxcity/simulator_gpu/solar/csf.py +1249 -0
  26. voxcity/simulator_gpu/solar/domain.py +561 -0
  27. voxcity/simulator_gpu/solar/epw.py +421 -0
  28. voxcity/simulator_gpu/solar/integration.py +2953 -0
  29. voxcity/simulator_gpu/solar/radiation.py +3019 -0
  30. voxcity/simulator_gpu/solar/raytracing.py +686 -0
  31. voxcity/simulator_gpu/solar/reflection.py +533 -0
  32. voxcity/simulator_gpu/solar/sky.py +907 -0
  33. voxcity/simulator_gpu/solar/solar.py +337 -0
  34. voxcity/simulator_gpu/solar/svf.py +446 -0
  35. voxcity/simulator_gpu/solar/volumetric.py +1151 -0
  36. voxcity/simulator_gpu/solar/voxcity.py +2953 -0
  37. voxcity/simulator_gpu/temporal.py +13 -0
  38. voxcity/simulator_gpu/utils.py +25 -0
  39. voxcity/simulator_gpu/view.py +32 -0
  40. voxcity/simulator_gpu/visibility/__init__.py +109 -0
  41. voxcity/simulator_gpu/visibility/geometry.py +278 -0
  42. voxcity/simulator_gpu/visibility/integration.py +808 -0
  43. voxcity/simulator_gpu/visibility/landmark.py +753 -0
  44. voxcity/simulator_gpu/visibility/view.py +944 -0
  45. voxcity/visualizer/renderer.py +2 -1
  46. {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/METADATA +16 -53
  47. {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/RECORD +50 -15
  48. {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
  49. {voxcity-1.0.2.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
  50. {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,