Skip to content

Commit 896630a

Browse files
kunifujiwaraclaude
andcommitted
Merge feat/canonical-vertices-orientation: canonical rectangle vertices + orientation vocabulary
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2 parents f96c18f + 47ec9d5 commit 896630a

19 files changed

Lines changed: 475 additions & 68 deletions

HISTORY.rst

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,22 @@
22
History
33
=======
44

5+
1.4.0 (2026-07-05)
6+
------------------
7+
8+
* ``rectangle_vertices`` are now validated and canonicalized to
9+
``[SW, NW, NE, SE]`` order at all public entry points (``get_voxcity``,
10+
``get_voxcity_CityGML`` and the four grid functions). Non-canonical
11+
input is reordered with a warning; closed 5-point rings are accepted;
12+
out-of-range lon/lat raises ``ValueError`` with a (lat, lon) hint.
13+
* Grid orientation conversions (GeoTIFF, MagicaVoxel, OBJ, rasterio
14+
interop, coastline masks) now go through named helpers in
15+
``voxcity.utils.orientation``. No behavior change. The ENVI-met exporter
16+
intentionally remains an exception: it keeps SOUTH_UP internally and
17+
writes north-first rows only at the file-format boundary.
18+
* Fixed contradictory 3D orientation statements in the documented grid
19+
contract.
20+
521
0.1.0 (2024-08-01)
622
------------------
723

docs/coordinate_systems_ja.md

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -83,9 +83,14 @@ GeoTIFF を読むときはファイル自身の CRS を尊重し、必要時の
8383

8484
座標系とは別に格子の行方向の規約がある([utils/orientation.py](../src/voxcity/utils/orientation.py)):
8585

86-
- 内部処理は **south_up**(行0=南=原点辺、行increase で北へ/列increase で東へ)
87-
- 可視化時のみ `np.flipud` で north_up に反転
88-
- 変換は `ensure_orientation()` のみで行う(境界での正規化用)
86+
- 内部処理は **south_up**(行0=南=原点辺、行increase で北へ/列increase で東へ)。3D ボクセル配列も水平方向は同じ向き(フリップなし)
87+
- 境界(I/O)での向き変換はすべて `utils/orientation.py` の名前付きヘルパーに集約:
88+
- `ensure_orientation()` — south_up ⇔ north_up の縦フリップ(GeoTIFF・海岸線マスク・可視化など)
89+
- `to_rasterio_layout()` / `from_rasterio_layout()` — uv 格子 ⇔ rasterio の (ny, nx) レイアウト(純粋な転置)
90+
- `grid_to_rotated_raster()` — 回転 AOI 用 GeoTIFF のレガシーレイアウト
91+
- `voxels_to_magicavoxel_axes()` / `voxels_to_kji()` — 3D ボクセルのフォーマット別軸順
92+
- **例外**:ENVI-met エクスポータは内部を south_up のまま保持し、行を北先頭で書き出す整形をファイル形式境界で行う(`arr[::-1]`)。これは意図的な設計で、`ensure_orientation()` は呼ばない(`tests/test_exporter_envimet.py``TestEnvimetSouthUpProcessing` がこれを保証)
93+
- `rectangle_vertices` は公開 API 入口で `normalize_rectangle_vertices()` により正準順 [SW, NW, NE, SE] に正規化される(非正準順は警告付きで並べ替え)
8994

9095
---
9196

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[tool.poetry]
22
name = "voxcity"
3-
version = "1.3.9"
3+
version = "1.4.0"
44
description = "voxcity is an easy and one-stop tool to output 3d city models for microclimate simulation by integrating multiple geospatial open-data"
55
readme = "README.md"
66
license = "MIT"

src/voxcity/downloader/ocean.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,12 @@
2121
import requests
2222
import numpy as np
2323

24+
from ..utils.orientation import (
25+
ensure_orientation,
26+
ORIENTATION_NORTH_UP,
27+
ORIENTATION_SOUTH_UP,
28+
)
29+
2430
# Cache directory for ocean detection results (optional)
2531
CACHE_DIR = Path(tempfile.gettempdir()) / "voxcity_ocean_cache"
2632

@@ -603,7 +609,7 @@ def apply_ocean_mask_to_grid(
603609
rectangle_vertices,
604610
grid.shape
605611
)
606-
land_mask = np.flipud(land_mask)
612+
land_mask = ensure_orientation(land_mask, ORIENTATION_NORTH_UP, ORIENTATION_SOUTH_UP)
607613

608614
# Apply ocean class to cells that are:
609615
# 1. Not land (ocean according to OSM land polygons)

src/voxcity/exporter/geotiff.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from affine import Affine
1313

1414
from ..geoprocessor.raster.core import compute_grid_geometry, compute_cell_center_coords
15+
from ..utils.orientation import grid_to_rotated_raster
1516

1617
__all__ = [
1718
"export_grid_geotiff",
@@ -105,7 +106,7 @@ def _north_up_affine_and_array(grid, rectangle_vertices, meshsize):
105106
dx * u_vec[0], -dy * v_vec[0], float(nw[0]),
106107
dx * u_vec[1], -dy * v_vec[1], float(nw[1]),
107108
)
108-
array = np.ascontiguousarray(np.flipud(grid.T))
109+
array = grid_to_rotated_raster(grid)
109110
return array, transform
110111

111112

src/voxcity/exporter/magicavoxel.py

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
import os
2626
from ..visualizer import get_voxel_color_map
2727
from ..utils.logging import get_logger
28+
from ..utils.orientation import voxels_to_magicavoxel_axes
2829

2930
_logger = get_logger(__name__)
3031

@@ -212,11 +213,7 @@ def numpy_to_vox(array, color_map, output_file):
212213
value_mapping = create_mapping(color_map)
213214
value_mapping[0] = 0 # Ensure 0 maps to 0 (void)
214215

215-
# VoxCity arrays use (north, east, height). pyvox expects dense arrays as
216-
# (y, z, x), and pyvox flips dense z internally when writing MagicaVoxel
217-
# voxels. Pre-flip height so MagicaVoxel receives z=height.
218-
array_flipped = np.flip(array, axis=2)
219-
array_transposed = np.transpose(array_flipped, (0, 2, 1)) # (north, height, east)
216+
array_transposed = voxels_to_magicavoxel_axes(array) # (north, height, east)
220217
mapped_array = np.vectorize(value_mapping.get)(array_transposed, 0)
221218

222219
# Create and save vox file

src/voxcity/exporter/obj.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,7 @@
4646
from ..visualizer import get_voxel_color_map
4747
from ..errors import ConfigurationError
4848
from ..utils.logging import get_logger
49+
from ..utils.orientation import voxels_to_kji
4950

5051
_logger = get_logger(__name__)
5152

@@ -387,7 +388,7 @@ def export_obj(array, output_dir, file_name, voxel_size=None, voxel_color_map=No
387388
# scene-space vertices: X = v/east (axis 1 of original), Y = u/north (axis 0),
388389
# Z = height (axis 2). Consistent with the Phase 3 uv-domain contract in
389390
# src/voxcity/simulator/common/coordinates.py.
390-
array = array.transpose(2, 0, 1) # (nk=height, ni=north/u, nj=east/v)
391+
array = voxels_to_kji(array) # (nk=height, ni=north/u, nj=east/v)
391392
size_x, size_y, size_z = array.shape # size_x=height, size_y=north, size_z=east
392393

393394
# Initialize data structures

src/voxcity/generator/__init__.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
66
Orientation contract:
77
- All 2D grids use uv_m/SOUTH_UP orientation (axis 0 = u/north, row 0 = southern origin edge; axis 1 = v/east).
8-
- 3D indexing follows (row, col, z) = (north→south, west→east, ground→up).
8+
- 3D voxel arrays index (i, j, k) with the same horizontal orientation as the
9+
2D grids (row 0 = southern origin edge, i increases northward) and k = ground→up.
910
"""
1011

1112
from .api import get_voxcity, get_voxcity_CityGML, auto_select_data_sources

src/voxcity/generator/api.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
)
2727
from ..utils.lc import get_land_cover_classes
2828
from ..geoprocessor.io import get_gdf_from_gpkg
29+
from ..geoprocessor.utils import normalize_rectangle_vertices
2930
from ..visualizer.grids import visualize_numerical_grid
3031
from ..utils.logging import get_logger
3132

@@ -466,7 +467,9 @@ def get_voxcity(rectangle_vertices, meshsize, building_source=None, land_cover_s
466467
Returns:
467468
VoxCity object containing the generated 3D city model
468469
"""
469-
470+
if rectangle_vertices is not None:
471+
rectangle_vertices = normalize_rectangle_vertices(rectangle_vertices)
472+
470473
# Check if building_complementary_source was provided via kwargs (for backward compatibility)
471474
if building_complementary_source is None and 'building_complementary_source' in kwargs:
472475
building_complementary_source = kwargs.pop('building_complementary_source')
@@ -722,6 +725,9 @@ def get_voxcity(rectangle_vertices, meshsize, building_source=None, land_cover_s
722725

723726

724727
def get_voxcity_CityGML(rectangle_vertices, land_cover_source, canopy_height_source, meshsize, url_citygml=None, citygml_path=None, **kwargs):
728+
if rectangle_vertices is not None:
729+
rectangle_vertices = normalize_rectangle_vertices(rectangle_vertices)
730+
725731
output_dir = kwargs.get("output_dir", "output")
726732
os.makedirs(output_dir, exist_ok=True)
727733
kwargs.pop('output_dir', None)

src/voxcity/generator/grids.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535

3636
from ..utils.lc import convert_land_cover_array, get_land_cover_classes, get_source_class_descriptions
3737
from ..geoprocessor.io import get_gdf_from_gpkg
38+
from ..geoprocessor.utils import normalize_rectangle_vertices
3839
from ..visualizer.grids import visualize_land_cover_grid, visualize_numerical_grid
3940
from ..utils.logging import get_logger
4041
from ..errors import ProcessingError
@@ -49,6 +50,9 @@ def get_last_effective_land_cover_source():
4950

5051

5152
def get_land_cover_grid(rectangle_vertices, meshsize, source, output_dir, print_class_info=True, **kwargs):
53+
if rectangle_vertices is not None:
54+
rectangle_vertices = normalize_rectangle_vertices(rectangle_vertices)
55+
5256
quiet = kwargs.get('quiet', False)
5357
if not quiet:
5458
_logger.info("Creating Land Use Land Cover grid\n ")
@@ -144,6 +148,9 @@ def get_land_cover_grid(rectangle_vertices, meshsize, source, output_dir, print_
144148

145149

146150
def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir, building_gdf=None, **kwargs):
151+
if rectangle_vertices is not None:
152+
rectangle_vertices = normalize_rectangle_vertices(rectangle_vertices)
153+
147154
ee_required_sources = {"Open Building 2.5D Temporal"}
148155
if source in ee_required_sources:
149156
initialize_earth_engine()
@@ -249,6 +256,9 @@ def get_building_height_grid(rectangle_vertices, meshsize, source, output_dir, b
249256

250257

251258
def get_canopy_height_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
259+
if rectangle_vertices is not None:
260+
rectangle_vertices = normalize_rectangle_vertices(rectangle_vertices)
261+
252262
quiet = kwargs.get('quiet', False)
253263
if not quiet:
254264
_logger.info("Creating Canopy Height grid\n ")
@@ -355,6 +365,9 @@ def get_canopy_height_grid(rectangle_vertices, meshsize, source, output_dir, **k
355365

356366

357367
def get_dem_grid(rectangle_vertices, meshsize, source, output_dir, **kwargs):
368+
if rectangle_vertices is not None:
369+
rectangle_vertices = normalize_rectangle_vertices(rectangle_vertices)
370+
358371
quiet = kwargs.get('quiet', False)
359372
if not quiet:
360373
_logger.info("Creating Digital Elevation Model (DEM) grid\n ")

0 commit comments

Comments
 (0)