voxcity 0.6.17__tar.gz → 0.6.18__tar.gz
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.
Potentially problematic release.
This version of voxcity might be problematic. Click here for more details.
- {voxcity-0.6.17 → voxcity-0.6.18}/PKG-INFO +4 -2
- {voxcity-0.6.17 → voxcity-0.6.18}/README.md +1 -1
- {voxcity-0.6.17 → voxcity-0.6.18}/pyproject.toml +3 -1
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/exporter/__init__.py +2 -1
- voxcity-0.6.18/src/voxcity/exporter/netcdf.py +211 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/exporter/obj.py +538 -1
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/utils/visualization.py +31 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/AUTHORS.rst +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/LICENSE +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/__init__.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/__init__.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/citygml.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/eubucco.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/gee.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/mbfp.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/oemj.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/osm.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/overture.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/downloader/utils.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/exporter/cityles.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/exporter/envimet.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/exporter/magicavoxel.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/generator.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/geoprocessor/__init__.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/geoprocessor/draw.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/geoprocessor/grid.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/geoprocessor/mesh.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/geoprocessor/network.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/geoprocessor/polygon.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/geoprocessor/utils.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/simulator/__init__.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/simulator/solar.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/simulator/utils.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/simulator/view.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/utils/__init__.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/utils/lc.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/utils/material.py +0 -0
- {voxcity-0.6.17 → voxcity-0.6.18}/src/voxcity/utils/weather.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.3
|
|
2
2
|
Name: voxcity
|
|
3
|
-
Version: 0.6.
|
|
3
|
+
Version: 0.6.18
|
|
4
4
|
Summary: voxcity is an easy and one-stop tool to output 3d city models for microclimate simulation by integrating multiple geospatial open-data
|
|
5
5
|
License: MIT
|
|
6
6
|
Author: Kunihiko Fujiwara
|
|
@@ -27,6 +27,7 @@ Requires-Dist: ipyleaflet
|
|
|
27
27
|
Requires-Dist: joblib
|
|
28
28
|
Requires-Dist: lxml
|
|
29
29
|
Requires-Dist: matplotlib
|
|
30
|
+
Requires-Dist: netCDF4
|
|
30
31
|
Requires-Dist: numba
|
|
31
32
|
Requires-Dist: numpy
|
|
32
33
|
Requires-Dist: osmnx
|
|
@@ -51,6 +52,7 @@ Requires-Dist: timezonefinder
|
|
|
51
52
|
Requires-Dist: tqdm
|
|
52
53
|
Requires-Dist: trimesh
|
|
53
54
|
Requires-Dist: typer
|
|
55
|
+
Requires-Dist: xarray
|
|
54
56
|
Project-URL: bugs, https://github.com/kunifujiwara/voxcity/issues
|
|
55
57
|
Project-URL: changelog, https://github.com/kunifujiwara/voxcity/blob/master/changelog.md
|
|
56
58
|
Project-URL: homepage, https://github.com/kunifujiwara/voxcity
|
|
@@ -232,7 +234,7 @@ Generate voxel data grids and corresponding building geoJSON:
|
|
|
232
234
|
from voxcity.generator import get_voxcity
|
|
233
235
|
|
|
234
236
|
voxcity_grid, building_height_grid, building_min_height_grid, \
|
|
235
|
-
building_id_grid, canopy_height_grid, land_cover_grid, dem_grid, \
|
|
237
|
+
building_id_grid, canopy_height_grid, canopy_bottom_height_grid, land_cover_grid, dem_grid, \
|
|
236
238
|
building_gdf = get_voxcity(
|
|
237
239
|
rectangle_vertices,
|
|
238
240
|
building_source,
|
|
@@ -174,7 +174,7 @@ Generate voxel data grids and corresponding building geoJSON:
|
|
|
174
174
|
from voxcity.generator import get_voxcity
|
|
175
175
|
|
|
176
176
|
voxcity_grid, building_height_grid, building_min_height_grid, \
|
|
177
|
-
building_id_grid, canopy_height_grid, land_cover_grid, dem_grid, \
|
|
177
|
+
building_id_grid, canopy_height_grid, canopy_bottom_height_grid, land_cover_grid, dem_grid, \
|
|
178
178
|
building_gdf = get_voxcity(
|
|
179
179
|
rectangle_vertices,
|
|
180
180
|
building_source,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[tool.poetry]
|
|
2
2
|
name = "voxcity"
|
|
3
|
-
version = "0.6.
|
|
3
|
+
version = "0.6.18"
|
|
4
4
|
description = "voxcity is an easy and one-stop tool to output 3d city models for microclimate simulation by integrating multiple geospatial open-data"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
license = "MIT"
|
|
@@ -56,6 +56,8 @@ pyvista = "*"
|
|
|
56
56
|
IPython = "*"
|
|
57
57
|
lxml = "*"
|
|
58
58
|
scikit-learn = "*"
|
|
59
|
+
xarray = "*"
|
|
60
|
+
netCDF4 = "*"
|
|
59
61
|
|
|
60
62
|
[tool.poetry.group.dev.dependencies]
|
|
61
63
|
coverage = "*"
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
"""
|
|
2
|
+
NetCDF export utilities for VoxCity.
|
|
3
|
+
|
|
4
|
+
This module provides functions to convert a 3D voxel grid produced by
|
|
5
|
+
`voxcity.generator.get_voxcity` into a NetCDF file for portable storage
|
|
6
|
+
and downstream analysis.
|
|
7
|
+
|
|
8
|
+
The voxel values follow VoxCity conventions (see generator.create_3d_voxel):
|
|
9
|
+
- -3: built structures (buildings)
|
|
10
|
+
- -2: vegetation canopy
|
|
11
|
+
- -1: subsurface/underground
|
|
12
|
+
- >= 1: ground-surface land cover code (offset by +1 from source classes)
|
|
13
|
+
|
|
14
|
+
Notes
|
|
15
|
+
-----
|
|
16
|
+
- This writer prefers xarray for NetCDF export. If xarray is not installed,
|
|
17
|
+
a clear error is raised with installation hints.
|
|
18
|
+
- Coordinates are stored as index-based distances in meters from the grid
|
|
19
|
+
origin along the y, x, and z axes. Geographic metadata such as the
|
|
20
|
+
`rectangle_vertices` and `meshsize_m` are stored as global attributes to
|
|
21
|
+
avoid making assumptions about map projection or geodesic conversions.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Dict, Mapping, MutableMapping, Optional, Sequence, Tuple
|
|
28
|
+
import json
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
|
|
32
|
+
try: # xarray is the preferred backend for NetCDF writing
|
|
33
|
+
import xarray as xr # type: ignore
|
|
34
|
+
XR_AVAILABLE = True
|
|
35
|
+
except Exception: # pragma: no cover - optional dependency
|
|
36
|
+
XR_AVAILABLE = False
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"voxel_to_xarray_dataset",
|
|
40
|
+
"save_voxel_netcdf",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _ensure_parent_dir(path: Path) -> None:
|
|
45
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def voxel_to_xarray_dataset(
|
|
49
|
+
voxcity_grid: np.ndarray,
|
|
50
|
+
voxel_size_m: float,
|
|
51
|
+
rectangle_vertices: Optional[Sequence[Tuple[float, float]]] = None,
|
|
52
|
+
extra_attrs: Optional[Mapping[str, Any]] = None,
|
|
53
|
+
) -> "xr.Dataset":
|
|
54
|
+
"""Create an xarray Dataset from a VoxCity voxel grid.
|
|
55
|
+
|
|
56
|
+
Parameters
|
|
57
|
+
----------
|
|
58
|
+
voxcity_grid
|
|
59
|
+
3D numpy array with shape (rows, cols, levels) as returned by
|
|
60
|
+
`get_voxcity` (first element of the returned tuple).
|
|
61
|
+
voxel_size_m
|
|
62
|
+
Voxel size (mesh size) in meters.
|
|
63
|
+
rectangle_vertices
|
|
64
|
+
Optional polygon vertices defining the area of interest in
|
|
65
|
+
longitude/latitude pairs, typically the same list passed to
|
|
66
|
+
`get_voxcity`.
|
|
67
|
+
extra_attrs
|
|
68
|
+
Optional mapping of additional global attributes to store in the
|
|
69
|
+
dataset.
|
|
70
|
+
|
|
71
|
+
Returns
|
|
72
|
+
-------
|
|
73
|
+
xr.Dataset
|
|
74
|
+
Dataset containing one DataArray named "voxels" with dims (y, x, z)
|
|
75
|
+
and coordinate variables in meters from origin.
|
|
76
|
+
"""
|
|
77
|
+
if not XR_AVAILABLE: # pragma: no cover - optional dependency
|
|
78
|
+
raise ImportError(
|
|
79
|
+
"xarray is required to export NetCDF. Install with: \n"
|
|
80
|
+
" pip install xarray netCDF4\n"
|
|
81
|
+
"or: \n"
|
|
82
|
+
" pip install xarray h5netcdf"
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
if voxcity_grid.ndim != 3:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
f"voxcity_grid must be 3D (rows, cols, levels); got shape={voxcity_grid.shape}"
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
rows, cols, levels = voxcity_grid.shape
|
|
91
|
+
|
|
92
|
+
# Coordinate vectors in meters relative to the grid origin
|
|
93
|
+
# y increases with row index, x increases with column index
|
|
94
|
+
y_m = np.arange(rows, dtype=float) * float(voxel_size_m)
|
|
95
|
+
x_m = np.arange(cols, dtype=float) * float(voxel_size_m)
|
|
96
|
+
z_m = np.arange(levels, dtype=float) * float(voxel_size_m)
|
|
97
|
+
|
|
98
|
+
ds_attrs: MutableMapping[str, Any] = {
|
|
99
|
+
"title": "VoxCity voxel grid",
|
|
100
|
+
"institution": "VoxCity",
|
|
101
|
+
"source": "voxcity.generator.create_3d_voxel",
|
|
102
|
+
"Conventions": "CF-1.10 (partial)",
|
|
103
|
+
# NetCDF attributes must be basic types; serialize complex structures as strings
|
|
104
|
+
"vox_value_meanings": [
|
|
105
|
+
"-3: building",
|
|
106
|
+
"-2: vegetation_canopy",
|
|
107
|
+
"-1: subsurface",
|
|
108
|
+
">=1: surface_land_cover_code (offset +1)",
|
|
109
|
+
],
|
|
110
|
+
"meshsize_m": float(voxel_size_m),
|
|
111
|
+
# Store vertices as JSON string for portability
|
|
112
|
+
"rectangle_vertices_lonlat_json": (
|
|
113
|
+
json.dumps([[float(v[0]), float(v[1])] for v in rectangle_vertices])
|
|
114
|
+
if rectangle_vertices is not None else ""
|
|
115
|
+
),
|
|
116
|
+
"vertical_reference": "z=0 corresponds to min(DEM) as used in voxel construction",
|
|
117
|
+
}
|
|
118
|
+
if extra_attrs:
|
|
119
|
+
ds_attrs.update(dict(extra_attrs))
|
|
120
|
+
|
|
121
|
+
da = xr.DataArray(
|
|
122
|
+
voxcity_grid,
|
|
123
|
+
dims=("y", "x", "z"),
|
|
124
|
+
coords={
|
|
125
|
+
"y": ("y", y_m, {"units": "m", "long_name": "row_distance_from_origin"}),
|
|
126
|
+
"x": ("x", x_m, {"units": "m", "long_name": "col_distance_from_origin"}),
|
|
127
|
+
"z": ("z", z_m, {"units": "m", "positive": "up", "long_name": "height_above_vertical_origin"}),
|
|
128
|
+
},
|
|
129
|
+
name="voxels",
|
|
130
|
+
attrs={
|
|
131
|
+
"units": "category",
|
|
132
|
+
"description": "VoxCity voxel values; see global attribute 'vox_value_meanings'",
|
|
133
|
+
},
|
|
134
|
+
)
|
|
135
|
+
|
|
136
|
+
ds = xr.Dataset({"voxels": da}, attrs=ds_attrs)
|
|
137
|
+
return ds
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def save_voxel_netcdf(
|
|
141
|
+
voxcity_grid: np.ndarray,
|
|
142
|
+
output_path: str | Path,
|
|
143
|
+
voxel_size_m: float,
|
|
144
|
+
rectangle_vertices: Optional[Sequence[Tuple[float, float]]] = None,
|
|
145
|
+
extra_attrs: Optional[Mapping[str, Any]] = None,
|
|
146
|
+
engine: Optional[str] = None,
|
|
147
|
+
) -> str:
|
|
148
|
+
"""Save a VoxCity voxel grid to a NetCDF file.
|
|
149
|
+
|
|
150
|
+
Parameters
|
|
151
|
+
----------
|
|
152
|
+
voxcity_grid
|
|
153
|
+
3D numpy array (rows, cols, levels) of voxel values.
|
|
154
|
+
output_path
|
|
155
|
+
Path to the NetCDF file to be written. Parent directories will be
|
|
156
|
+
created as needed.
|
|
157
|
+
voxel_size_m
|
|
158
|
+
Voxel size in meters.
|
|
159
|
+
rectangle_vertices
|
|
160
|
+
Optional list of (lon, lat) pairs defining the area of interest.
|
|
161
|
+
Stored as dataset metadata only.
|
|
162
|
+
extra_attrs
|
|
163
|
+
Optional additional global attributes to embed in the dataset.
|
|
164
|
+
engine
|
|
165
|
+
Optional xarray engine, e.g., "netcdf4" or "h5netcdf". If not provided,
|
|
166
|
+
xarray will choose a default; on failure we retry alternate engines.
|
|
167
|
+
|
|
168
|
+
Returns
|
|
169
|
+
-------
|
|
170
|
+
str
|
|
171
|
+
The string path to the written NetCDF file.
|
|
172
|
+
"""
|
|
173
|
+
if not XR_AVAILABLE: # pragma: no cover - optional dependency
|
|
174
|
+
raise ImportError(
|
|
175
|
+
"xarray is required to export NetCDF. Install with: \n"
|
|
176
|
+
" pip install xarray netCDF4\n"
|
|
177
|
+
"or: \n"
|
|
178
|
+
" pip install xarray h5netcdf"
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
path = Path(output_path)
|
|
182
|
+
_ensure_parent_dir(path)
|
|
183
|
+
|
|
184
|
+
ds = voxel_to_xarray_dataset(
|
|
185
|
+
voxcity_grid=voxcity_grid,
|
|
186
|
+
voxel_size_m=voxel_size_m,
|
|
187
|
+
rectangle_vertices=rectangle_vertices,
|
|
188
|
+
extra_attrs=extra_attrs,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Attempt to save with the requested or default engine; on failure, try a fallback
|
|
192
|
+
tried_engines = []
|
|
193
|
+
try:
|
|
194
|
+
ds.to_netcdf(path, engine=engine) # type: ignore[call-arg]
|
|
195
|
+
except Exception as e_first: # pragma: no cover - I/O backend dependent
|
|
196
|
+
tried_engines.append(engine or "default")
|
|
197
|
+
for fallback in ("netcdf4", "h5netcdf"):
|
|
198
|
+
try:
|
|
199
|
+
ds.to_netcdf(path, engine=fallback) # type: ignore[call-arg]
|
|
200
|
+
break
|
|
201
|
+
except Exception:
|
|
202
|
+
tried_engines.append(fallback)
|
|
203
|
+
else:
|
|
204
|
+
raise RuntimeError(
|
|
205
|
+
f"Failed to write NetCDF using engines: {tried_engines}. "
|
|
206
|
+
f"Original error: {e_first}"
|
|
207
|
+
)
|
|
208
|
+
|
|
209
|
+
return str(path)
|
|
210
|
+
|
|
211
|
+
|
|
@@ -652,4 +652,541 @@ def grid_to_obj(value_array_ori, dem_array_ori, output_dir, file_name, cell_size
|
|
|
652
652
|
f.write(f'd {a:.6f}\n') # Transparency (alpha)
|
|
653
653
|
f.write('\n')
|
|
654
654
|
|
|
655
|
-
print(f'OBJ and MTL files have been generated in {output_dir} with the base name "{file_name}".')
|
|
655
|
+
print(f'OBJ and MTL files have been generated in {output_dir} with the base name "{file_name}".')
|
|
656
|
+
|
|
657
|
+
|
|
658
|
+
def export_netcdf_to_obj(
|
|
659
|
+
voxcity_nc,
|
|
660
|
+
scalar_nc,
|
|
661
|
+
lonlat_txt,
|
|
662
|
+
output_dir,
|
|
663
|
+
vox_base_filename="voxcity_objects",
|
|
664
|
+
tm_base_filename="tm_isosurfaces",
|
|
665
|
+
scalar_var="tm",
|
|
666
|
+
scalar_building_value=-999.99,
|
|
667
|
+
scalar_building_tol=1e-4,
|
|
668
|
+
stride_vox=(1, 1, 1),
|
|
669
|
+
stride_scalar=(1, 1, 1),
|
|
670
|
+
contour_levels=24,
|
|
671
|
+
cmap_name="magma",
|
|
672
|
+
opacity_points=None,
|
|
673
|
+
max_opacity=0.10,
|
|
674
|
+
classes_to_show=None,
|
|
675
|
+
voxel_color_scheme="default",
|
|
676
|
+
max_faces_warn=1_000_000,
|
|
677
|
+
):
|
|
678
|
+
"""
|
|
679
|
+
Export two OBJ/MTL files using the same local meter frame:
|
|
680
|
+
- VoxCity voxels: opaque, per-class color, fixed face winding and normals
|
|
681
|
+
- Scalar iso-surfaces: colormap colors with variable transparency
|
|
682
|
+
|
|
683
|
+
The two outputs share the same XY origin and axes (X east, Y north, Z up),
|
|
684
|
+
anchored at the minimum lon/lat of the VoxCity bounding rectangle.
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
voxcity_nc (str): Path to VoxCity NetCDF (must include variable 'voxels' and coords 'x','y','z').
|
|
688
|
+
scalar_nc (str): Path to scalar NetCDF containing variable specified by scalar_var.
|
|
689
|
+
lonlat_txt (str): Text file with columns: i j lon lat (1-based indices) describing the scalar grid georef.
|
|
690
|
+
output_dir (str): Directory to write results.
|
|
691
|
+
vox_base_filename (str): Base filename for VoxCity OBJ/MTL.
|
|
692
|
+
tm_base_filename (str): Base filename for scalar iso-surfaces OBJ/MTL.
|
|
693
|
+
scalar_var (str): Name of scalar variable in scalar_nc.
|
|
694
|
+
scalar_building_value (float): Value used in scalar field to mark buildings (to be masked).
|
|
695
|
+
scalar_building_tol (float): Tolerance for building masking (isclose).
|
|
696
|
+
stride_vox (tuple[int,int,int]): Downsampling strides for VoxCity (z,y,x) in voxels.
|
|
697
|
+
stride_scalar (tuple[int,int,int]): Downsampling strides for scalar (k,j,i).
|
|
698
|
+
contour_levels (int): Number of iso-surface levels between vmin and vmax.
|
|
699
|
+
cmap_name (str): Matplotlib colormap name for iso-surfaces.
|
|
700
|
+
opacity_points (list[tuple[float,float]]|None): Transfer function control points (value, alpha in [0..1]).
|
|
701
|
+
max_opacity (float): Global max opacity multiplier for iso-surfaces (0..1).
|
|
702
|
+
classes_to_show (set[int]|None): Optional subset of voxel classes to export; None -> all present (except 0).
|
|
703
|
+
voxel_color_scheme (str): Color scheme name passed to get_voxel_color_map.
|
|
704
|
+
max_faces_warn (int): Warn if a single class exceeds this many faces.
|
|
705
|
+
|
|
706
|
+
Returns:
|
|
707
|
+
dict: Paths of written files: keys 'vox_obj','vox_mtl','tm_obj','tm_mtl' (values may be None).
|
|
708
|
+
"""
|
|
709
|
+
import json
|
|
710
|
+
import numpy as np
|
|
711
|
+
import os
|
|
712
|
+
import xarray as xr
|
|
713
|
+
import trimesh
|
|
714
|
+
|
|
715
|
+
try:
|
|
716
|
+
from skimage import measure as skim
|
|
717
|
+
except Exception as e: # pragma: no cover - optional dependency
|
|
718
|
+
raise ImportError(
|
|
719
|
+
"scikit-image is required for iso-surface generation. Install 'scikit-image'."
|
|
720
|
+
) from e
|
|
721
|
+
|
|
722
|
+
from matplotlib import cm
|
|
723
|
+
|
|
724
|
+
if opacity_points is None:
|
|
725
|
+
opacity_points = [(-0.2, 0.00), (2.0, 1.00)]
|
|
726
|
+
|
|
727
|
+
def find_dims(ds):
|
|
728
|
+
lvl = ["k", "level", "lev", "z", "height", "alt", "plev"]
|
|
729
|
+
yy = ["j", "y", "south_north", "lat", "latitude"]
|
|
730
|
+
xx = ["i", "x", "west_east", "lon", "longitude"]
|
|
731
|
+
tt = ["time", "Times"]
|
|
732
|
+
|
|
733
|
+
def pick(cands):
|
|
734
|
+
for c in cands:
|
|
735
|
+
if c in ds.dims:
|
|
736
|
+
return c
|
|
737
|
+
return None
|
|
738
|
+
|
|
739
|
+
t = pick(tt)
|
|
740
|
+
k = pick(lvl)
|
|
741
|
+
j = pick(yy)
|
|
742
|
+
i = pick(xx)
|
|
743
|
+
if (k is None or j is None or i is None) and len(ds.dims) >= 3:
|
|
744
|
+
dims = list(ds.dims)
|
|
745
|
+
k = k or dims[0]
|
|
746
|
+
j = j or dims[-2]
|
|
747
|
+
i = i or dims[-1]
|
|
748
|
+
return t, k, j, i
|
|
749
|
+
|
|
750
|
+
def squeeze_to_kji(da, tname, kname, jname, iname, time_index=0):
|
|
751
|
+
if tname and tname in da.dims:
|
|
752
|
+
da = da.isel({tname: time_index})
|
|
753
|
+
for d in list(da.dims):
|
|
754
|
+
if d not in (kname, jname, iname):
|
|
755
|
+
da = da.isel({d: 0})
|
|
756
|
+
return da.transpose(*(d for d in (kname, jname, iname) if d in da.dims))
|
|
757
|
+
|
|
758
|
+
def downsample3(a, sk, sj, si):
|
|
759
|
+
return a[:: max(1, sk), :: max(1, sj), :: max(1, si)]
|
|
760
|
+
|
|
761
|
+
def clip_minmax(arr, frac):
|
|
762
|
+
v = np.asarray(arr)
|
|
763
|
+
v = v[np.isfinite(v)]
|
|
764
|
+
if v.size == 0:
|
|
765
|
+
return 0.0, 1.0
|
|
766
|
+
if frac <= 0:
|
|
767
|
+
return float(np.nanmin(v)), float(np.nanmax(v))
|
|
768
|
+
vmin_ = float(np.nanpercentile(v, 100 * frac))
|
|
769
|
+
vmax_ = float(np.nanpercentile(v, 100 * (1 - frac)))
|
|
770
|
+
if vmin_ >= vmax_:
|
|
771
|
+
vmin_, vmax_ = float(np.nanmin(v)), float(np.nanmax(v))
|
|
772
|
+
return vmin_, vmax_
|
|
773
|
+
|
|
774
|
+
def meters_per_degree(lat_rad):
|
|
775
|
+
m_per_deg_lat = 111132.92 - 559.82 * np.cos(2 * lat_rad) + 1.175 * np.cos(4 * lat_rad) - 0.0023 * np.cos(6 * lat_rad)
|
|
776
|
+
m_per_deg_lon = 111412.84 * np.cos(lat_rad) - 93.5 * np.cos(3 * lat_rad) + 0.118 * np.cos(5 * lat_rad)
|
|
777
|
+
return m_per_deg_lat, m_per_deg_lon
|
|
778
|
+
|
|
779
|
+
def opacity_at(v, points):
|
|
780
|
+
if not points:
|
|
781
|
+
return 0.0 if np.isscalar(v) else np.zeros_like(v)
|
|
782
|
+
pts = sorted((float(x), float(a)) for x, a in points)
|
|
783
|
+
xs = np.array([p[0] for p in pts], dtype=float)
|
|
784
|
+
as_ = np.array([p[1] for p in pts], dtype=float)
|
|
785
|
+
v_arr = np.asarray(v, dtype=float)
|
|
786
|
+
out = np.empty_like(v_arr, dtype=float)
|
|
787
|
+
out[v_arr <= xs[0]] = as_[0]
|
|
788
|
+
out[v_arr >= xs[-1]] = as_[-1]
|
|
789
|
+
idx = np.searchsorted(xs, v_arr, side="right") - 1
|
|
790
|
+
idx = np.clip(idx, 0, len(xs) - 2)
|
|
791
|
+
x0, x1 = xs[idx], xs[idx + 1]
|
|
792
|
+
a0, a1 = as_[idx], as_[idx + 1]
|
|
793
|
+
t = np.where(x1 > x0, (v_arr - x0) / (x1 - x0), 0.0)
|
|
794
|
+
mid = (v_arr > xs[0]) & (v_arr < xs[-1])
|
|
795
|
+
out[mid] = a0[mid] + t[mid] * (a1[mid] - a0[mid])
|
|
796
|
+
return out.item() if np.isscalar(v) else out
|
|
797
|
+
|
|
798
|
+
def _exposed_face_masks(occ):
|
|
799
|
+
K, J, I = occ.shape
|
|
800
|
+
p = np.pad(occ, ((0, 0), (0, 0), (0, 1)), constant_values=False)
|
|
801
|
+
posx = occ & (~p[..., 1:])
|
|
802
|
+
p = np.pad(occ, ((0, 0), (0, 0), (1, 0)), constant_values=False)
|
|
803
|
+
negx = occ & (~p[..., :-1])
|
|
804
|
+
p = np.pad(occ, ((0, 0), (0, 1), (0, 0)), constant_values=False)
|
|
805
|
+
posy = occ & (~p[:, 1:, :])
|
|
806
|
+
p = np.pad(occ, ((0, 0), (1, 0), (0, 0)), constant_values=False)
|
|
807
|
+
negy = occ & (~p[:, :-1, :])
|
|
808
|
+
p = np.pad(occ, ((0, 1), (0, 0), (0, 0)), constant_values=False)
|
|
809
|
+
posz = occ & (~p[1:, :, :])
|
|
810
|
+
p = np.pad(occ, ((1, 0), (0, 0), (0, 0)), constant_values=False)
|
|
811
|
+
negz = occ & (~p[:-1, :, :])
|
|
812
|
+
return posx, negx, posy, negy, posz, negz
|
|
813
|
+
|
|
814
|
+
def _emit_faces_trimesh(k, j, i, plane, X, Y, Z, start_idx):
|
|
815
|
+
N = k.size
|
|
816
|
+
if N == 0:
|
|
817
|
+
return np.empty((0, 3)), np.empty((0, 3), dtype=np.int64), start_idx
|
|
818
|
+
|
|
819
|
+
dx = (X[1] - X[0]) if len(X) > 1 else 1.0
|
|
820
|
+
dy = (Y[1] - Y[0]) if len(Y) > 1 else 1.0
|
|
821
|
+
dz = (Z[1] - Z[0]) if len(Z) > 1 else 1.0
|
|
822
|
+
|
|
823
|
+
x = X[i].astype(np.float64)
|
|
824
|
+
y = Y[j].astype(np.float64)
|
|
825
|
+
z = Z[k].astype(np.float64)
|
|
826
|
+
hx, hy, hz = dx / 2.0, dy / 2.0, dz / 2.0
|
|
827
|
+
|
|
828
|
+
if plane == "+x":
|
|
829
|
+
P = np.column_stack([x + hx, y - hy, z - hz])
|
|
830
|
+
Q = np.column_stack([x + hx, y + hy, z - hz])
|
|
831
|
+
R = np.column_stack([x + hx, y + hy, z + hz])
|
|
832
|
+
S = np.column_stack([x + hx, y - hy, z + hz])
|
|
833
|
+
order = "default"
|
|
834
|
+
elif plane == "-x":
|
|
835
|
+
P = np.column_stack([x - hx, y - hy, z + hz])
|
|
836
|
+
Q = np.column_stack([x - hx, y + hy, z + hz])
|
|
837
|
+
R = np.column_stack([x - hx, y + hy, z - hz])
|
|
838
|
+
S = np.column_stack([x - hx, y - hy, z - hz])
|
|
839
|
+
order = "default"
|
|
840
|
+
elif plane == "+y":
|
|
841
|
+
P = np.column_stack([x - hx, y + hy, z - hz])
|
|
842
|
+
Q = np.column_stack([x + hx, y + hy, z - hz])
|
|
843
|
+
R = np.column_stack([x + hx, y + hy, z + hz])
|
|
844
|
+
S = np.column_stack([x - hx, y + hy, z + hz])
|
|
845
|
+
order = "flip" # enforce outward normals
|
|
846
|
+
elif plane == "-y":
|
|
847
|
+
P = np.column_stack([x - hx, y - hy, z + hz])
|
|
848
|
+
Q = np.column_stack([x + hx, y - hy, z + hz])
|
|
849
|
+
R = np.column_stack([x + hx, y - hy, z - hz])
|
|
850
|
+
S = np.column_stack([x - hx, y - hy, z - hz])
|
|
851
|
+
order = "flip"
|
|
852
|
+
elif plane == "+z":
|
|
853
|
+
P = np.column_stack([x - hx, y - hy, z + hz])
|
|
854
|
+
Q = np.column_stack([x + hx, y - hy, z + hz])
|
|
855
|
+
R = np.column_stack([x + hx, y + hy, z + hz])
|
|
856
|
+
S = np.column_stack([x - hx, y + hy, z + hz])
|
|
857
|
+
order = "default"
|
|
858
|
+
else: # "-z"
|
|
859
|
+
P = np.column_stack([x - hx, y + hy, z - hz])
|
|
860
|
+
Q = np.column_stack([x + hx, y + hy, z - hz])
|
|
861
|
+
R = np.column_stack([x + hx, y - hy, z - hz])
|
|
862
|
+
S = np.column_stack([x - hx, y - hy, z - hz])
|
|
863
|
+
order = "default"
|
|
864
|
+
|
|
865
|
+
verts = np.vstack([P, Q, R, S])
|
|
866
|
+
a = np.arange(N, dtype=np.int64) + start_idx
|
|
867
|
+
b = a + N
|
|
868
|
+
c = a + 2 * N
|
|
869
|
+
d = a + 3 * N
|
|
870
|
+
|
|
871
|
+
if order == "default":
|
|
872
|
+
tris = np.vstack([np.column_stack([a, b, c]), np.column_stack([a, c, d])])
|
|
873
|
+
else:
|
|
874
|
+
tris = np.vstack([np.column_stack([a, c, b]), np.column_stack([a, d, c])])
|
|
875
|
+
|
|
876
|
+
return verts, tris, start_idx + 4 * N
|
|
877
|
+
|
|
878
|
+
def make_voxel_mesh_uniform_color(occ_mask, X, Y, Z, rgb, name="class"):
|
|
879
|
+
posx, negx, posy, negy, posz, negz = _exposed_face_masks(occ_mask.astype(bool))
|
|
880
|
+
total_faces = int(posx.sum() + negx.sum() + posy.sum() + negy.sum() + posz.sum() + negz.sum())
|
|
881
|
+
if total_faces == 0:
|
|
882
|
+
return None, 0
|
|
883
|
+
if total_faces > max_faces_warn:
|
|
884
|
+
print(f" Warning: {name} faces={total_faces:,} (> {max_faces_warn:,}). Consider increasing stride.")
|
|
885
|
+
|
|
886
|
+
verts_all, tris_all, start_idx = [], [], 0
|
|
887
|
+
for plane, mask in (("+x", posx), ("-x", negx), ("+y", posy), ("-y", negy), ("+z", posz), ("-z", negz)):
|
|
888
|
+
idx = np.argwhere(mask)
|
|
889
|
+
if idx.size == 0:
|
|
890
|
+
continue
|
|
891
|
+
k, j, i = idx[:, 0], idx[:, 1], idx[:, 2]
|
|
892
|
+
Vp, Tp, start_idx = _emit_faces_trimesh(k, j, i, plane, X, Y, Z, start_idx)
|
|
893
|
+
verts_all.append(Vp)
|
|
894
|
+
tris_all.append(Tp)
|
|
895
|
+
|
|
896
|
+
V = np.vstack(verts_all)
|
|
897
|
+
F = np.vstack(tris_all)
|
|
898
|
+
mesh = trimesh.Trimesh(vertices=V, faces=F, process=False)
|
|
899
|
+
rgba = np.array([int(rgb[0]), int(rgb[1]), int(rgb[2]), 255], dtype=np.uint8)
|
|
900
|
+
mesh.visual.face_colors = np.tile(rgba, (len(F), 1))
|
|
901
|
+
return mesh, len(F)
|
|
902
|
+
|
|
903
|
+
def build_tm_isosurfaces_regular_grid(A_scalar, vmin, vmax, levels, dx, dy, dz, origin_xyz, cmap_name, opacity_points, max_opacity):
|
|
904
|
+
cmap = cm.get_cmap(cmap_name)
|
|
905
|
+
meshes = []
|
|
906
|
+
if levels <= 0:
|
|
907
|
+
return meshes
|
|
908
|
+
iso_vals = np.linspace(vmin, vmax, int(levels))
|
|
909
|
+
for iso in iso_vals:
|
|
910
|
+
a_base = float(opacity_at(iso, opacity_points or []))
|
|
911
|
+
a_base = min(max(a_base, 0.0), 1.0)
|
|
912
|
+
alpha = a_base * max_opacity
|
|
913
|
+
if alpha <= 0.0:
|
|
914
|
+
continue
|
|
915
|
+
try:
|
|
916
|
+
verts, faces, _, _ = skim.marching_cubes(A_scalar, level=iso, spacing=(dz, dy, dx))
|
|
917
|
+
except Exception:
|
|
918
|
+
continue
|
|
919
|
+
if len(verts) == 0 or len(faces) == 0:
|
|
920
|
+
continue
|
|
921
|
+
V = verts[:, [2, 1, 0]].astype(np.float64)
|
|
922
|
+
V += np.array(origin_xyz, dtype=np.float64)[None, :]
|
|
923
|
+
m = trimesh.Trimesh(vertices=V, faces=faces.astype(np.int64), process=False)
|
|
924
|
+
t = 0.0 if vmax <= vmin else (iso - vmin) / (vmax - vmin)
|
|
925
|
+
r, g, b, _ = cmap(np.clip(t, 0.0, 1.0))
|
|
926
|
+
rgba = (
|
|
927
|
+
int(round(255 * r)),
|
|
928
|
+
int(round(255 * g)),
|
|
929
|
+
int(round(255 * b)),
|
|
930
|
+
int(round(255 * alpha)),
|
|
931
|
+
)
|
|
932
|
+
m.visual.face_colors = np.tile(np.array(rgba, dtype=np.uint8), (len(m.faces), 1))
|
|
933
|
+
meshes.append((iso, m, rgba))
|
|
934
|
+
print(f"Iso {iso:.4f}: faces={len(m.faces):,}, alpha={alpha:.4f}")
|
|
935
|
+
return meshes
|
|
936
|
+
|
|
937
|
+
def save_obj_with_mtl_and_normals(meshes_dict, output_path, base_filename):
|
|
938
|
+
os.makedirs(output_path, exist_ok=True)
|
|
939
|
+
obj_path = os.path.join(output_path, f"{base_filename}.obj")
|
|
940
|
+
mtl_path = os.path.join(output_path, f"{base_filename}.mtl")
|
|
941
|
+
|
|
942
|
+
def to_uint8_rgba(arr):
|
|
943
|
+
arr = np.asarray(arr)
|
|
944
|
+
if arr.dtype != np.uint8:
|
|
945
|
+
if arr.dtype.kind == "f":
|
|
946
|
+
arr = np.clip(arr, 0.0, 1.0)
|
|
947
|
+
arr = (arr * 255.0 + 0.5).astype(np.uint8)
|
|
948
|
+
else:
|
|
949
|
+
arr = arr.astype(np.uint8)
|
|
950
|
+
if arr.shape[1] == 3:
|
|
951
|
+
arr = np.concatenate([arr, np.full((arr.shape[0], 1), 255, np.uint8)], axis=1)
|
|
952
|
+
return arr
|
|
953
|
+
|
|
954
|
+
color_to_id, ordered = {}, []
|
|
955
|
+
|
|
956
|
+
def mid_of(rgba):
|
|
957
|
+
if rgba not in color_to_id:
|
|
958
|
+
color_to_id[rgba] = len(ordered)
|
|
959
|
+
ordered.append(rgba)
|
|
960
|
+
return color_to_id[rgba]
|
|
961
|
+
|
|
962
|
+
for m in meshes_dict.values():
|
|
963
|
+
fc = getattr(m.visual, "face_colors", None)
|
|
964
|
+
if fc is None or len(fc) == 0:
|
|
965
|
+
mid_of((200, 200, 200, 255))
|
|
966
|
+
continue
|
|
967
|
+
for rgba in np.unique(to_uint8_rgba(fc), axis=0):
|
|
968
|
+
mid_of(tuple(int(x) for x in rgba.tolist()))
|
|
969
|
+
|
|
970
|
+
with open(mtl_path, "w") as mtl:
|
|
971
|
+
for i, (r, g, b, a) in enumerate(ordered):
|
|
972
|
+
kd = (r / 255.0, g / 255.0, b / 255.0)
|
|
973
|
+
ka = kd
|
|
974
|
+
dval = a / 255.0
|
|
975
|
+
tr = max(0.0, min(1.0, 1.0 - dval))
|
|
976
|
+
mtl.write(f"newmtl material_{i}\n")
|
|
977
|
+
mtl.write(f"Kd {kd[0]:.6f} {kd[1]:.6f} {kd[2]:.6f}\n")
|
|
978
|
+
mtl.write(f"Ka {ka[0]:.6f} {ka[1]:.6f} {ka[2]:.6f}\n")
|
|
979
|
+
mtl.write("Ks 0.000000 0.000000 0.000000\n")
|
|
980
|
+
mtl.write("Ns 0.000000\n")
|
|
981
|
+
mtl.write("illum 1\n")
|
|
982
|
+
mtl.write(f"d {dval:.6f}\n")
|
|
983
|
+
mtl.write(f"Tr {tr:.6f}\n\n")
|
|
984
|
+
|
|
985
|
+
def face_normals(V, F):
|
|
986
|
+
v0, v1, v2 = V[F[:, 0]], V[F[:, 1]], V[F[:, 2]]
|
|
987
|
+
n = np.cross(v1 - v0, v2 - v0)
|
|
988
|
+
L = np.linalg.norm(n, axis=1)
|
|
989
|
+
mask = L > 0
|
|
990
|
+
n[mask] /= L[mask][:, None]
|
|
991
|
+
if (~mask).any():
|
|
992
|
+
n[~mask] = np.array([0.0, 0.0, 1.0])
|
|
993
|
+
return n
|
|
994
|
+
|
|
995
|
+
with open(obj_path, "w") as obj:
|
|
996
|
+
obj.write(f"mtllib {os.path.basename(mtl_path)}\n")
|
|
997
|
+
v_offset = 0
|
|
998
|
+
n_offset = 0
|
|
999
|
+
for name, m in meshes_dict.items():
|
|
1000
|
+
V = np.asarray(m.vertices, dtype=np.float64)
|
|
1001
|
+
F = np.asarray(m.faces, dtype=np.int64)
|
|
1002
|
+
if len(V) == 0 or len(F) == 0:
|
|
1003
|
+
continue
|
|
1004
|
+
obj.write(f"o {name}\n")
|
|
1005
|
+
obj.write("s off\n")
|
|
1006
|
+
for vx, vy, vz in V:
|
|
1007
|
+
obj.write(f"v {vx:.6f} {vy:.6f} {vz:.6f}\n")
|
|
1008
|
+
|
|
1009
|
+
fc = getattr(m.visual, "face_colors", None)
|
|
1010
|
+
if fc is None or len(fc) != len(F):
|
|
1011
|
+
fc = np.tile(np.array([200, 200, 200, 255], dtype=np.uint8), (len(F), 1))
|
|
1012
|
+
else:
|
|
1013
|
+
fc = to_uint8_rgba(fc)
|
|
1014
|
+
uniq, inv = np.unique(fc, axis=0, return_inverse=True)
|
|
1015
|
+
color2mid = {tuple(int(x) for x in c.tolist()): mid_of(tuple(int(x) for x in c.tolist())) for c in uniq}
|
|
1016
|
+
|
|
1017
|
+
FN = face_normals(V, F)
|
|
1018
|
+
for nx, ny, nz in FN:
|
|
1019
|
+
obj.write(f"vn {float(nx):.6f} {float(ny):.6f} {float(nz):.6f}\n")
|
|
1020
|
+
|
|
1021
|
+
current_mid = None
|
|
1022
|
+
for i_face, face in enumerate(F):
|
|
1023
|
+
key = tuple(int(x) for x in uniq[inv[i_face]].tolist())
|
|
1024
|
+
mid = color2mid[key]
|
|
1025
|
+
if current_mid != mid:
|
|
1026
|
+
obj.write(f"usemtl material_{mid}\n")
|
|
1027
|
+
current_mid = mid
|
|
1028
|
+
a, b, c = face + 1 + v_offset
|
|
1029
|
+
ni = n_offset + i_face + 1
|
|
1030
|
+
obj.write(f"f {a}//{ni} {b}//{ni} {c}//{ni}\n")
|
|
1031
|
+
|
|
1032
|
+
v_offset += len(V)
|
|
1033
|
+
n_offset += len(F)
|
|
1034
|
+
|
|
1035
|
+
return obj_path, mtl_path
|
|
1036
|
+
|
|
1037
|
+
# Load VoxCity
|
|
1038
|
+
dsv = xr.open_dataset(voxcity_nc)
|
|
1039
|
+
if "voxels" not in dsv:
|
|
1040
|
+
raise KeyError("'voxels' not found in VoxCity dataset.")
|
|
1041
|
+
dav = dsv["voxels"]
|
|
1042
|
+
if tuple(dav.dims) != ("y", "x", "z") and all(d in dav.dims for d in ("y", "x", "z")):
|
|
1043
|
+
dav = dav.transpose("y", "x", "z")
|
|
1044
|
+
|
|
1045
|
+
Yv = dsv["y"].values.astype(float)
|
|
1046
|
+
Xv = dsv["x"].values.astype(float)
|
|
1047
|
+
Zv = dsv["z"].values.astype(float)
|
|
1048
|
+
|
|
1049
|
+
Av = dav.values # (y,x,z)
|
|
1050
|
+
Av_kji = np.transpose(Av, (2, 0, 1)) # (K=z, J=y, I=x)
|
|
1051
|
+
svz, svy, svx = stride_vox
|
|
1052
|
+
Av_kji = downsample3(Av_kji, svz, svy, svx)
|
|
1053
|
+
# Y flip (north-up)
|
|
1054
|
+
Av_kji = Av_kji[:, ::-1, :]
|
|
1055
|
+
|
|
1056
|
+
Zv_s = Zv[:: max(1, svz)].astype(float)
|
|
1057
|
+
Yv_s = (Yv[:: max(1, svy)] - Yv.min()).astype(float)
|
|
1058
|
+
Xv_s = (Xv[:: max(1, svx)] - Xv.min()).astype(float)
|
|
1059
|
+
|
|
1060
|
+
# Load scalar and georeference using lon/lat table
|
|
1061
|
+
dss = xr.open_dataset(scalar_nc, decode_coords="all", decode_times=True)
|
|
1062
|
+
tname, kname, jname, iname = find_dims(dss)
|
|
1063
|
+
if scalar_var not in dss:
|
|
1064
|
+
raise KeyError(f"{scalar_var} not found in scalar dataset")
|
|
1065
|
+
|
|
1066
|
+
A = squeeze_to_kji(dss[scalar_var], tname, kname, jname, iname).values # (K,J,I)
|
|
1067
|
+
K0, J0, I0 = map(int, A.shape)
|
|
1068
|
+
|
|
1069
|
+
ll = np.loadtxt(lonlat_txt, comments="#")
|
|
1070
|
+
ii = ll[:, 0].astype(int) - 1
|
|
1071
|
+
jj = ll[:, 1].astype(int) - 1
|
|
1072
|
+
lon = ll[:, 2].astype(float)
|
|
1073
|
+
lat = ll[:, 3].astype(float)
|
|
1074
|
+
I_ll = int(ii.max() + 1)
|
|
1075
|
+
J_ll = int(jj.max() + 1)
|
|
1076
|
+
lon_grid = np.full((J_ll, I_ll), np.nan, float)
|
|
1077
|
+
lat_grid = np.full((J_ll, I_ll), np.nan, float)
|
|
1078
|
+
lon_grid[jj, ii] = lon
|
|
1079
|
+
lat_grid[jj, ii] = lat
|
|
1080
|
+
|
|
1081
|
+
Jc = min(J0, J_ll)
|
|
1082
|
+
Ic = min(I0, I_ll)
|
|
1083
|
+
if (Jc != J0) or (Ic != I0):
|
|
1084
|
+
print(
|
|
1085
|
+
f"Warning: scalar (J,I)=({J0},{I0}) vs lonlat ({J_ll},{I_ll}); using common ({Jc},{Ic})."
|
|
1086
|
+
)
|
|
1087
|
+
A = A[:, :Jc, :Ic]
|
|
1088
|
+
lon_grid = lon_grid[:Jc, :Ic]
|
|
1089
|
+
lat_grid = lat_grid[:Jc, :Ic]
|
|
1090
|
+
|
|
1091
|
+
ssk, ssj, ssi = stride_scalar
|
|
1092
|
+
A_s = downsample3(A, ssk, ssj, ssi)
|
|
1093
|
+
lon_s = lon_grid[:: max(1, ssj), :: max(1, ssi)]
|
|
1094
|
+
lat_s = lat_grid[:: max(1, ssj), :: max(1, ssi)]
|
|
1095
|
+
Ks, Js, Is = A_s.shape
|
|
1096
|
+
|
|
1097
|
+
rect = np.array(json.loads(dsv.attrs.get("rectangle_vertices_lonlat_json", "[]")), float)
|
|
1098
|
+
if rect.size == 0:
|
|
1099
|
+
raise RuntimeError("VoxCity attribute 'rectangle_vertices_lonlat_json' missing.")
|
|
1100
|
+
lon0 = float(np.min(rect[:, 0]))
|
|
1101
|
+
lat0 = float(np.min(rect[:, 1]))
|
|
1102
|
+
lat_c = float(np.mean(rect[:, 1]))
|
|
1103
|
+
m_per_deg_lat, m_per_deg_lon = meters_per_degree(np.deg2rad(lat_c))
|
|
1104
|
+
Xs_m = (lon_s - lon0) * m_per_deg_lon
|
|
1105
|
+
Ys_m = (lat_s - lat0) * m_per_deg_lat
|
|
1106
|
+
|
|
1107
|
+
if (kname is not None) and (kname in dss.coords):
|
|
1108
|
+
zc = dss.coords[kname].values
|
|
1109
|
+
if np.issubdtype(zc.dtype, np.number) and zc.ndim == 1 and len(zc) >= Ks:
|
|
1110
|
+
Zk = zc.astype(float)[:: max(1, ssk)][:Ks]
|
|
1111
|
+
else:
|
|
1112
|
+
Zk = np.arange(Ks, dtype=float) * float(dsv.attrs.get("meshsize_m", 1.0))
|
|
1113
|
+
else:
|
|
1114
|
+
Zk = np.arange(Ks, dtype=float) * float(dsv.attrs.get("meshsize_m", 1.0))
|
|
1115
|
+
|
|
1116
|
+
# Mask scalar buildings
|
|
1117
|
+
bmask_scalar = downsample3(
|
|
1118
|
+
np.isclose(A, scalar_building_value, atol=scalar_building_tol), ssk, ssj, ssi
|
|
1119
|
+
)
|
|
1120
|
+
A_s = A_s.astype(float)
|
|
1121
|
+
A_s[bmask_scalar] = np.nan
|
|
1122
|
+
|
|
1123
|
+
finite_vals = A_s[np.isfinite(A_s)]
|
|
1124
|
+
if finite_vals.size == 0:
|
|
1125
|
+
raise RuntimeError("No finite scalar values after masking.")
|
|
1126
|
+
vmin, vmax = clip_minmax(finite_vals, 0.0)
|
|
1127
|
+
A_s[np.isnan(A_s)] = vmin - 1e6
|
|
1128
|
+
|
|
1129
|
+
Xmin, Xmax = np.nanmin(Xs_m), np.nanmax(Xs_m)
|
|
1130
|
+
Ymin, Ymax = np.nanmin(Ys_m), np.nanmax(Ys_m)
|
|
1131
|
+
dx_s = (Xmax - Xmin) / max(1, Is - 1)
|
|
1132
|
+
dy_s = (Ymax - Ymin) / max(1, Js - 1)
|
|
1133
|
+
dz_s = (Zk[-1] - Zk[0]) / max(1, Ks - 1) if Ks > 1 else 1.0
|
|
1134
|
+
origin_xyz = (float(Xmin), float(Ymin), float(Zk[0]))
|
|
1135
|
+
|
|
1136
|
+
vox_meshes = {}
|
|
1137
|
+
tm_meshes = {}
|
|
1138
|
+
|
|
1139
|
+
present = set(np.unique(Av_kji))
|
|
1140
|
+
present.discard(0)
|
|
1141
|
+
if classes_to_show is not None:
|
|
1142
|
+
present &= set(classes_to_show)
|
|
1143
|
+
present = sorted(present)
|
|
1144
|
+
|
|
1145
|
+
faces_total = 0
|
|
1146
|
+
voxel_color_map = get_voxel_color_map(color_scheme=voxel_color_scheme)
|
|
1147
|
+
for cls in present:
|
|
1148
|
+
mask = Av_kji == cls
|
|
1149
|
+
if not np.any(mask):
|
|
1150
|
+
continue
|
|
1151
|
+
rgb = voxel_color_map.get(int(cls), [200, 200, 200])
|
|
1152
|
+
m_cls, faces = make_voxel_mesh_uniform_color(mask, Xv_s, Yv_s, Zv_s, rgb=rgb, name=f"class_{int(cls)}")
|
|
1153
|
+
if m_cls is not None:
|
|
1154
|
+
vox_meshes[f"voxclass_{int(cls)}"] = m_cls
|
|
1155
|
+
faces_total += faces
|
|
1156
|
+
print(f"[VoxCity] total voxel faces: {faces_total:,}")
|
|
1157
|
+
|
|
1158
|
+
iso_meshes = build_tm_isosurfaces_regular_grid(
|
|
1159
|
+
A_scalar=A_s,
|
|
1160
|
+
vmin=vmin,
|
|
1161
|
+
vmax=vmax,
|
|
1162
|
+
levels=contour_levels,
|
|
1163
|
+
dx=dx_s,
|
|
1164
|
+
dy=dy_s,
|
|
1165
|
+
dz=dz_s,
|
|
1166
|
+
origin_xyz=origin_xyz,
|
|
1167
|
+
cmap_name=cmap_name,
|
|
1168
|
+
opacity_points=opacity_points,
|
|
1169
|
+
max_opacity=max_opacity,
|
|
1170
|
+
)
|
|
1171
|
+
for iso, m, rgba in iso_meshes:
|
|
1172
|
+
tm_meshes[f"iso_{iso:.6f}"] = m
|
|
1173
|
+
|
|
1174
|
+
if not vox_meshes and not tm_meshes:
|
|
1175
|
+
raise RuntimeError("Nothing to export.")
|
|
1176
|
+
|
|
1177
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
1178
|
+
obj_vox = mtl_vox = obj_tm = mtl_tm = None
|
|
1179
|
+
if vox_meshes:
|
|
1180
|
+
obj_vox, mtl_vox = save_obj_with_mtl_and_normals(vox_meshes, output_dir, vox_base_filename)
|
|
1181
|
+
if tm_meshes:
|
|
1182
|
+
obj_tm, mtl_tm = save_obj_with_mtl_and_normals(tm_meshes, output_dir, tm_base_filename)
|
|
1183
|
+
|
|
1184
|
+
print("Export finished.")
|
|
1185
|
+
if obj_vox:
|
|
1186
|
+
print(f"VoxCity OBJ: {obj_vox}")
|
|
1187
|
+
print(f"VoxCity MTL: {mtl_vox}")
|
|
1188
|
+
if obj_tm:
|
|
1189
|
+
print(f"Scalar Iso OBJ: {obj_tm}")
|
|
1190
|
+
print(f"Scalar Iso MTL: {mtl_tm}")
|
|
1191
|
+
|
|
1192
|
+
return {"vox_obj": obj_vox, "vox_mtl": mtl_vox, "tm_obj": obj_tm, "tm_mtl": mtl_tm}
|
|
@@ -111,6 +111,7 @@ def get_voxel_color_map(color_scheme='default'):
|
|
|
111
111
|
- 'pastel': Softer, muted colors for aesthetic appeal
|
|
112
112
|
- 'dark_mode': Darker colors for dark backgrounds
|
|
113
113
|
- 'grayscale': Black and white gradient with color accents
|
|
114
|
+
- 'white_mode': Light minimal palette for white backgrounds
|
|
114
115
|
|
|
115
116
|
Thematic Schemes:
|
|
116
117
|
- 'autumn': Warm reds, oranges, and browns
|
|
@@ -329,6 +330,36 @@ def get_voxel_color_map(color_scheme='default'):
|
|
|
329
330
|
14: [230, 230, 230], # 'No Data'
|
|
330
331
|
}
|
|
331
332
|
|
|
333
|
+
elif color_scheme == 'white_mode':
|
|
334
|
+
return {
|
|
335
|
+
-99: [0, 0, 0], # void (transparent in rendering)
|
|
336
|
+
-30: [220, 80, 80], # subtle highlight for landmarks
|
|
337
|
+
-17: [250, 250, 250], # plaster (near white)
|
|
338
|
+
-16: [210, 225, 235], # glass (light blue-gray)
|
|
339
|
+
-15: [230, 225, 215], # stone (warm light gray)
|
|
340
|
+
-14: [225, 230, 235], # metal (cool light gray)
|
|
341
|
+
-13: [236, 236, 236], # concrete (very light gray)
|
|
342
|
+
-12: [245, 232, 210], # wood (light beige)
|
|
343
|
+
-11: [235, 210, 205], # brick (light rose)
|
|
344
|
+
-3: [225, 230, 240], # Building (soft blue-gray)
|
|
345
|
+
-2: [190, 210, 190], # Tree (soft green)
|
|
346
|
+
-1: [230, 215, 215], # Underground (soft pinkish)
|
|
347
|
+
1: [248, 245, 235], # Bareland
|
|
348
|
+
2: [225, 235, 215], # Rangeland
|
|
349
|
+
3: [220, 235, 220], # Shrub
|
|
350
|
+
4: [240, 235, 215], # Agriculture land
|
|
351
|
+
5: [210, 230, 210], # Tree (ground)
|
|
352
|
+
6: [245, 250, 235], # Moss and lichen
|
|
353
|
+
7: [220, 235, 230], # Wet land
|
|
354
|
+
8: [205, 215, 210], # Mangrove
|
|
355
|
+
9: [200, 220, 245], # Water (pale blue)
|
|
356
|
+
10: [252, 252, 252], # Snow and ice (almost white)
|
|
357
|
+
11: [230, 230, 230], # Developed space
|
|
358
|
+
12: [210, 210, 215], # Road (light neutral)
|
|
359
|
+
13: [230, 235, 240], # Building (ground surface)
|
|
360
|
+
14: [248, 245, 235], # No Data
|
|
361
|
+
}
|
|
362
|
+
|
|
332
363
|
elif color_scheme == 'autumn':
|
|
333
364
|
return {
|
|
334
365
|
-99: [0, 0, 0], # void
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|