voxcity 0.7.0__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/__init__.py +14 -14
- voxcity/downloader/ocean.py +559 -0
- voxcity/exporter/__init__.py +12 -12
- voxcity/exporter/cityles.py +633 -633
- voxcity/exporter/envimet.py +733 -728
- voxcity/exporter/magicavoxel.py +333 -333
- voxcity/exporter/netcdf.py +238 -238
- voxcity/exporter/obj.py +1480 -1480
- voxcity/generator/__init__.py +47 -44
- voxcity/generator/api.py +727 -675
- voxcity/generator/grids.py +394 -379
- voxcity/generator/io.py +94 -94
- voxcity/generator/pipeline.py +582 -282
- voxcity/generator/update.py +429 -0
- voxcity/generator/voxelizer.py +18 -6
- voxcity/geoprocessor/__init__.py +75 -75
- voxcity/geoprocessor/draw.py +1494 -1219
- voxcity/geoprocessor/merge_utils.py +91 -91
- voxcity/geoprocessor/mesh.py +806 -806
- voxcity/geoprocessor/network.py +708 -708
- voxcity/geoprocessor/raster/__init__.py +2 -0
- voxcity/geoprocessor/raster/buildings.py +435 -428
- voxcity/geoprocessor/raster/core.py +31 -0
- voxcity/geoprocessor/raster/export.py +93 -93
- voxcity/geoprocessor/raster/landcover.py +178 -51
- voxcity/geoprocessor/raster/raster.py +1 -1
- voxcity/geoprocessor/utils.py +824 -824
- voxcity/models.py +115 -113
- voxcity/simulator/solar/__init__.py +66 -43
- voxcity/simulator/solar/integration.py +336 -336
- voxcity/simulator/solar/sky.py +668 -0
- voxcity/simulator/solar/temporal.py +792 -434
- voxcity/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/utils/__init__.py +11 -0
- voxcity/utils/classes.py +194 -0
- voxcity/utils/lc.py +80 -39
- voxcity/utils/shape.py +230 -0
- voxcity/visualizer/__init__.py +24 -24
- voxcity/visualizer/builder.py +43 -43
- voxcity/visualizer/grids.py +141 -141
- voxcity/visualizer/maps.py +187 -187
- voxcity/visualizer/renderer.py +1146 -928
- {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/METADATA +56 -52
- voxcity-1.0.13.dist-info/RECORD +116 -0
- voxcity-0.7.0.dist-info/RECORD +0 -77
- {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/WHEEL +0 -0
- {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/AUTHORS.rst +0 -0
- {voxcity-0.7.0.dist-info → voxcity-1.0.13.dist-info}/licenses/LICENSE +0 -0
voxcity/generator/pipeline.py
CHANGED
|
@@ -1,282 +1,582 @@
|
|
|
1
|
-
import os
|
|
2
|
-
from ..utils.logging import get_logger
|
|
3
|
-
from typing import Optional
|
|
4
|
-
import
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
self.
|
|
30
|
-
self.
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
"
|
|
68
|
-
"
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
canopy_top
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
#
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
1
|
+
import os
|
|
2
|
+
from ..utils.logging import get_logger
|
|
3
|
+
from typing import Optional
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
from ..models import (
|
|
8
|
+
GridMetadata,
|
|
9
|
+
BuildingGrid,
|
|
10
|
+
LandCoverGrid,
|
|
11
|
+
DemGrid,
|
|
12
|
+
VoxelGrid,
|
|
13
|
+
CanopyGrid,
|
|
14
|
+
VoxCity,
|
|
15
|
+
PipelineConfig,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
from .grids import (
|
|
19
|
+
get_land_cover_grid,
|
|
20
|
+
get_building_height_grid,
|
|
21
|
+
get_canopy_height_grid,
|
|
22
|
+
get_dem_grid,
|
|
23
|
+
)
|
|
24
|
+
from .voxelizer import Voxelizer
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class VoxCityPipeline:
|
|
28
|
+
def __init__(self, meshsize: float, rectangle_vertices, crs: str = "EPSG:4326") -> None:
|
|
29
|
+
self.meshsize = float(meshsize)
|
|
30
|
+
self.rectangle_vertices = rectangle_vertices
|
|
31
|
+
self.crs = crs
|
|
32
|
+
|
|
33
|
+
def _bounds(self):
|
|
34
|
+
xs = [p[0] for p in self.rectangle_vertices]
|
|
35
|
+
ys = [p[1] for p in self.rectangle_vertices]
|
|
36
|
+
return (min(xs), min(ys), max(xs), max(ys))
|
|
37
|
+
|
|
38
|
+
def _meta(self) -> GridMetadata:
|
|
39
|
+
return GridMetadata(crs=self.crs, bounds=self._bounds(), meshsize=self.meshsize)
|
|
40
|
+
|
|
41
|
+
def assemble_voxcity(
|
|
42
|
+
self,
|
|
43
|
+
voxcity_grid: np.ndarray,
|
|
44
|
+
building_height_grid: np.ndarray,
|
|
45
|
+
building_min_height_grid: np.ndarray,
|
|
46
|
+
building_id_grid: np.ndarray,
|
|
47
|
+
land_cover_grid: np.ndarray,
|
|
48
|
+
dem_grid: np.ndarray,
|
|
49
|
+
canopy_height_top: Optional[np.ndarray] = None,
|
|
50
|
+
canopy_height_bottom: Optional[np.ndarray] = None,
|
|
51
|
+
extras: Optional[dict] = None,
|
|
52
|
+
) -> VoxCity:
|
|
53
|
+
meta = self._meta()
|
|
54
|
+
buildings = BuildingGrid(
|
|
55
|
+
heights=building_height_grid,
|
|
56
|
+
min_heights=building_min_height_grid,
|
|
57
|
+
ids=building_id_grid,
|
|
58
|
+
meta=meta,
|
|
59
|
+
)
|
|
60
|
+
land = LandCoverGrid(classes=land_cover_grid, meta=meta)
|
|
61
|
+
dem = DemGrid(elevation=dem_grid, meta=meta)
|
|
62
|
+
voxels = VoxelGrid(classes=voxcity_grid, meta=meta)
|
|
63
|
+
canopy = CanopyGrid(top=canopy_height_top if canopy_height_top is not None else np.zeros_like(land_cover_grid, dtype=float),
|
|
64
|
+
bottom=canopy_height_bottom,
|
|
65
|
+
meta=meta)
|
|
66
|
+
_extras = {
|
|
67
|
+
"rectangle_vertices": self.rectangle_vertices,
|
|
68
|
+
"canopy_top": canopy.top,
|
|
69
|
+
"canopy_bottom": canopy.bottom,
|
|
70
|
+
}
|
|
71
|
+
if extras:
|
|
72
|
+
_extras.update(extras)
|
|
73
|
+
return VoxCity(voxels=voxels, buildings=buildings, land_cover=land, dem=dem, tree_canopy=canopy, extras=_extras)
|
|
74
|
+
|
|
75
|
+
def run(self, cfg: PipelineConfig, building_gdf=None, terrain_gdf=None, **kwargs) -> VoxCity:
|
|
76
|
+
os.makedirs(cfg.output_dir, exist_ok=True)
|
|
77
|
+
land_strategy = LandCoverSourceFactory.create(cfg.land_cover_source)
|
|
78
|
+
build_strategy = BuildingSourceFactory.create(cfg.building_source)
|
|
79
|
+
canopy_strategy = CanopySourceFactory.create(cfg.canopy_height_source, cfg)
|
|
80
|
+
dem_strategy = DemSourceFactory.create(cfg.dem_source)
|
|
81
|
+
|
|
82
|
+
# Check if parallel download is enabled
|
|
83
|
+
parallel_download = getattr(cfg, 'parallel_download', False)
|
|
84
|
+
|
|
85
|
+
if parallel_download and cfg.canopy_height_source != "Static":
|
|
86
|
+
# All 4 downloads are independent - run in parallel
|
|
87
|
+
land_cover_grid, bh, bmin, bid, building_gdf_out, canopy_top, canopy_bottom, dem, lc_src_effective = \
|
|
88
|
+
self._run_parallel_downloads(
|
|
89
|
+
cfg, land_strategy, build_strategy, canopy_strategy, dem_strategy,
|
|
90
|
+
building_gdf, terrain_gdf, kwargs
|
|
91
|
+
)
|
|
92
|
+
# Run visualizations after parallel downloads complete (if gridvis enabled)
|
|
93
|
+
if kwargs.get('gridvis', cfg.gridvis):
|
|
94
|
+
self._visualize_grids_after_parallel(
|
|
95
|
+
land_cover_grid, bh, canopy_top, dem,
|
|
96
|
+
lc_src_effective, cfg.meshsize
|
|
97
|
+
)
|
|
98
|
+
elif parallel_download and cfg.canopy_height_source == "Static":
|
|
99
|
+
# Static canopy needs land_cover_grid for tree mask
|
|
100
|
+
# Run land_cover + building + dem in parallel, then canopy sequentially
|
|
101
|
+
land_cover_grid, bh, bmin, bid, building_gdf_out, dem, lc_src_effective = \
|
|
102
|
+
self._run_parallel_downloads_static_canopy(
|
|
103
|
+
cfg, land_strategy, build_strategy, dem_strategy,
|
|
104
|
+
building_gdf, terrain_gdf, kwargs
|
|
105
|
+
)
|
|
106
|
+
# Visualize land_cover, building, dem after parallel (if gridvis enabled)
|
|
107
|
+
if kwargs.get('gridvis', cfg.gridvis):
|
|
108
|
+
self._visualize_grids_after_parallel(
|
|
109
|
+
land_cover_grid, bh, None, dem,
|
|
110
|
+
lc_src_effective, cfg.meshsize
|
|
111
|
+
)
|
|
112
|
+
# Now run canopy with land_cover_grid available (this will visualize itself)
|
|
113
|
+
canopy_top, canopy_bottom = canopy_strategy.build_grids(
|
|
114
|
+
cfg.rectangle_vertices, cfg.meshsize, land_cover_grid, cfg.output_dir,
|
|
115
|
+
land_cover_source=lc_src_effective,
|
|
116
|
+
**{**cfg.canopy_options, **kwargs}
|
|
117
|
+
)
|
|
118
|
+
else:
|
|
119
|
+
# Sequential mode (original behavior)
|
|
120
|
+
land_cover_grid = land_strategy.build_grid(
|
|
121
|
+
cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
|
|
122
|
+
**{**cfg.land_cover_options, **kwargs}
|
|
123
|
+
)
|
|
124
|
+
# Detect effective land cover source (e.g., Urbanwatch -> OpenStreetMap fallback)
|
|
125
|
+
try:
|
|
126
|
+
from .grids import get_last_effective_land_cover_source
|
|
127
|
+
lc_src_effective = get_last_effective_land_cover_source() or cfg.land_cover_source
|
|
128
|
+
except Exception:
|
|
129
|
+
lc_src_effective = cfg.land_cover_source
|
|
130
|
+
bh, bmin, bid, building_gdf_out = build_strategy.build_grids(
|
|
131
|
+
cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
|
|
132
|
+
building_gdf=building_gdf,
|
|
133
|
+
**{**cfg.building_options, **kwargs}
|
|
134
|
+
)
|
|
135
|
+
canopy_top, canopy_bottom = canopy_strategy.build_grids(
|
|
136
|
+
cfg.rectangle_vertices, cfg.meshsize, land_cover_grid, cfg.output_dir,
|
|
137
|
+
land_cover_source=lc_src_effective,
|
|
138
|
+
**{**cfg.canopy_options, **kwargs}
|
|
139
|
+
)
|
|
140
|
+
dem = dem_strategy.build_grid(
|
|
141
|
+
cfg.rectangle_vertices, cfg.meshsize, land_cover_grid, cfg.output_dir,
|
|
142
|
+
terrain_gdf=terrain_gdf,
|
|
143
|
+
land_cover_like=land_cover_grid,
|
|
144
|
+
**{**cfg.dem_options, **kwargs}
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
ro = cfg.remove_perimeter_object
|
|
148
|
+
if (ro is not None) and (ro > 0):
|
|
149
|
+
w_peri = int(ro * bh.shape[0] + 0.5)
|
|
150
|
+
h_peri = int(ro * bh.shape[1] + 0.5)
|
|
151
|
+
canopy_top[:w_peri, :] = canopy_top[-w_peri:, :] = canopy_top[:, :h_peri] = canopy_top[:, -h_peri:] = 0
|
|
152
|
+
canopy_bottom[:w_peri, :] = canopy_bottom[-w_peri:, :] = canopy_bottom[:, :h_peri] = canopy_bottom[:, -h_peri:] = 0
|
|
153
|
+
ids1 = np.unique(bid[:w_peri, :][bid[:w_peri, :] > 0]); ids2 = np.unique(bid[-w_peri:, :][bid[-w_peri:, :] > 0])
|
|
154
|
+
ids3 = np.unique(bid[:, :h_peri][bid[:, :h_peri] > 0]); ids4 = np.unique(bid[:, -h_peri:][bid[:, -h_peri:] > 0])
|
|
155
|
+
for rid in np.concatenate((ids1, ids2, ids3, ids4)):
|
|
156
|
+
pos = np.where(bid == rid)
|
|
157
|
+
bh[pos] = 0
|
|
158
|
+
bmin[pos] = [[] for _ in range(len(bmin[pos]))]
|
|
159
|
+
|
|
160
|
+
voxelizer = Voxelizer(
|
|
161
|
+
voxel_size=cfg.meshsize,
|
|
162
|
+
land_cover_source=lc_src_effective,
|
|
163
|
+
trunk_height_ratio=cfg.trunk_height_ratio,
|
|
164
|
+
voxel_dtype=kwargs.get("voxel_dtype", np.int8),
|
|
165
|
+
max_voxel_ram_mb=kwargs.get("max_voxel_ram_mb"),
|
|
166
|
+
)
|
|
167
|
+
vox = voxelizer.generate_combined(
|
|
168
|
+
building_height_grid_ori=bh,
|
|
169
|
+
building_min_height_grid_ori=bmin,
|
|
170
|
+
building_id_grid_ori=bid,
|
|
171
|
+
land_cover_grid_ori=land_cover_grid,
|
|
172
|
+
dem_grid_ori=dem,
|
|
173
|
+
tree_grid_ori=canopy_top,
|
|
174
|
+
canopy_bottom_height_grid_ori=canopy_bottom,
|
|
175
|
+
)
|
|
176
|
+
return self.assemble_voxcity(
|
|
177
|
+
voxcity_grid=vox,
|
|
178
|
+
building_height_grid=bh,
|
|
179
|
+
building_min_height_grid=bmin,
|
|
180
|
+
building_id_grid=bid,
|
|
181
|
+
land_cover_grid=land_cover_grid,
|
|
182
|
+
dem_grid=dem,
|
|
183
|
+
canopy_height_top=canopy_top,
|
|
184
|
+
canopy_height_bottom=canopy_bottom,
|
|
185
|
+
extras={
|
|
186
|
+
"building_gdf": building_gdf_out,
|
|
187
|
+
"land_cover_source": lc_src_effective,
|
|
188
|
+
"building_source": cfg.building_source,
|
|
189
|
+
"dem_source": cfg.dem_source,
|
|
190
|
+
},
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
def _visualize_grids_after_parallel(
|
|
194
|
+
self, land_cover_grid, building_height_grid, canopy_top, dem_grid,
|
|
195
|
+
land_cover_source, meshsize
|
|
196
|
+
):
|
|
197
|
+
"""
|
|
198
|
+
Run grid visualizations after parallel downloads complete.
|
|
199
|
+
This ensures matplotlib calls happen sequentially on the main thread.
|
|
200
|
+
"""
|
|
201
|
+
from ..visualizer.grids import visualize_land_cover_grid, visualize_numerical_grid
|
|
202
|
+
from ..utils.lc import get_land_cover_classes
|
|
203
|
+
|
|
204
|
+
# Visualize land cover (convert int grid back to string for visualization)
|
|
205
|
+
try:
|
|
206
|
+
land_cover_classes = get_land_cover_classes(land_cover_source)
|
|
207
|
+
# Create reverse mapping: int -> string class name
|
|
208
|
+
int_to_class = {i: name for i, name in enumerate(land_cover_classes.values())}
|
|
209
|
+
# Convert integer grid to string grid for visualization
|
|
210
|
+
land_cover_grid_str = np.empty(land_cover_grid.shape, dtype=object)
|
|
211
|
+
for i, name in int_to_class.items():
|
|
212
|
+
land_cover_grid_str[land_cover_grid == i] = name
|
|
213
|
+
color_map = {cls: [r/255, g/255, b/255] for (r,g,b), cls in land_cover_classes.items()}
|
|
214
|
+
visualize_land_cover_grid(np.flipud(land_cover_grid_str), meshsize, color_map, land_cover_classes)
|
|
215
|
+
except Exception as e:
|
|
216
|
+
get_logger(__name__).warning("Land cover visualization failed: %s", e)
|
|
217
|
+
|
|
218
|
+
# Visualize building height
|
|
219
|
+
try:
|
|
220
|
+
building_height_grid_nan = building_height_grid.copy().astype(float)
|
|
221
|
+
building_height_grid_nan[building_height_grid_nan == 0] = np.nan
|
|
222
|
+
visualize_numerical_grid(np.flipud(building_height_grid_nan), meshsize, "building height (m)", cmap='viridis', label='Value')
|
|
223
|
+
except Exception as e:
|
|
224
|
+
get_logger(__name__).warning("Building height visualization failed: %s", e)
|
|
225
|
+
|
|
226
|
+
# Visualize canopy height (if provided)
|
|
227
|
+
if canopy_top is not None:
|
|
228
|
+
try:
|
|
229
|
+
canopy_height_grid_nan = canopy_top.copy()
|
|
230
|
+
canopy_height_grid_nan[canopy_height_grid_nan == 0] = np.nan
|
|
231
|
+
visualize_numerical_grid(np.flipud(canopy_height_grid_nan), meshsize, "Tree canopy height", cmap='Greens', label='Tree canopy height (m)')
|
|
232
|
+
except Exception as e:
|
|
233
|
+
get_logger(__name__).warning("Canopy height visualization failed: %s", e)
|
|
234
|
+
|
|
235
|
+
# Visualize DEM
|
|
236
|
+
try:
|
|
237
|
+
visualize_numerical_grid(np.flipud(dem_grid), meshsize, title='Digital Elevation Model', cmap='terrain', label='Elevation (m)')
|
|
238
|
+
except Exception as e:
|
|
239
|
+
get_logger(__name__).warning("DEM visualization failed: %s", e)
|
|
240
|
+
|
|
241
|
+
def _run_parallel_downloads(
|
|
242
|
+
self, cfg, land_strategy, build_strategy, canopy_strategy, dem_strategy,
|
|
243
|
+
building_gdf, terrain_gdf, kwargs
|
|
244
|
+
):
|
|
245
|
+
"""
|
|
246
|
+
Run all 4 downloads (land_cover, building, canopy, dem) in parallel.
|
|
247
|
+
Used when canopy source is NOT 'Static' (no land_cover dependency).
|
|
248
|
+
"""
|
|
249
|
+
import logging
|
|
250
|
+
logger = get_logger(__name__)
|
|
251
|
+
|
|
252
|
+
# Print clean header for parallel mode
|
|
253
|
+
print("\n" + "="*60)
|
|
254
|
+
print("Downloading data in parallel mode (4 concurrent downloads)")
|
|
255
|
+
print("="*60)
|
|
256
|
+
print(f" • Land Cover: {cfg.land_cover_source}")
|
|
257
|
+
print(f" • Building: {cfg.building_source}")
|
|
258
|
+
print(f" • Canopy: {cfg.canopy_height_source}")
|
|
259
|
+
print(f" • DEM: {cfg.dem_source}")
|
|
260
|
+
print("-"*60)
|
|
261
|
+
print("Downloading... (this may take a moment)")
|
|
262
|
+
|
|
263
|
+
results = {}
|
|
264
|
+
|
|
265
|
+
# Disable gridvis and verbose prints in parallel mode
|
|
266
|
+
# Also suppress httpx INFO logs during parallel downloads
|
|
267
|
+
parallel_kwargs = {**kwargs, 'gridvis': False, 'print_class_info': False, 'quiet': True}
|
|
268
|
+
lc_opts = {**cfg.land_cover_options, 'gridvis': False, 'print_class_info': False, 'quiet': True}
|
|
269
|
+
bld_opts = {**cfg.building_options, 'gridvis': False, 'quiet': True}
|
|
270
|
+
canopy_opts = {**cfg.canopy_options, 'gridvis': False, 'quiet': True}
|
|
271
|
+
dem_opts = {**cfg.dem_options, 'gridvis': False, 'quiet': True}
|
|
272
|
+
|
|
273
|
+
# Suppress httpx verbose logging during parallel downloads
|
|
274
|
+
httpx_logger = logging.getLogger("httpx")
|
|
275
|
+
original_httpx_level = httpx_logger.level
|
|
276
|
+
httpx_logger.setLevel(logging.WARNING)
|
|
277
|
+
|
|
278
|
+
def download_land_cover():
|
|
279
|
+
grid = land_strategy.build_grid(
|
|
280
|
+
cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
|
|
281
|
+
**{**lc_opts, **parallel_kwargs}
|
|
282
|
+
)
|
|
283
|
+
# Get effective source after download
|
|
284
|
+
try:
|
|
285
|
+
from .grids import get_last_effective_land_cover_source
|
|
286
|
+
effective = get_last_effective_land_cover_source() or cfg.land_cover_source
|
|
287
|
+
except Exception:
|
|
288
|
+
effective = cfg.land_cover_source
|
|
289
|
+
return ('land_cover', (grid, effective))
|
|
290
|
+
|
|
291
|
+
def download_building():
|
|
292
|
+
bh, bmin, bid, gdf_out = build_strategy.build_grids(
|
|
293
|
+
cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
|
|
294
|
+
building_gdf=building_gdf,
|
|
295
|
+
**{**bld_opts, **parallel_kwargs}
|
|
296
|
+
)
|
|
297
|
+
return ('building', (bh, bmin, bid, gdf_out))
|
|
298
|
+
|
|
299
|
+
def download_canopy():
|
|
300
|
+
# For non-static canopy, we don't need land_cover_grid
|
|
301
|
+
# Pass None or empty array as placeholder - the strategy will download from GEE
|
|
302
|
+
placeholder_grid = np.zeros((1, 1), dtype=float)
|
|
303
|
+
top, bottom = canopy_strategy.build_grids(
|
|
304
|
+
cfg.rectangle_vertices, cfg.meshsize, placeholder_grid, cfg.output_dir,
|
|
305
|
+
land_cover_source=cfg.land_cover_source,
|
|
306
|
+
**{**canopy_opts, **parallel_kwargs}
|
|
307
|
+
)
|
|
308
|
+
return ('canopy', (top, bottom))
|
|
309
|
+
|
|
310
|
+
def download_dem():
|
|
311
|
+
# DEM no longer depends on land_cover_like for shape
|
|
312
|
+
dem = dem_strategy.build_grid(
|
|
313
|
+
cfg.rectangle_vertices, cfg.meshsize, None, cfg.output_dir,
|
|
314
|
+
terrain_gdf=terrain_gdf,
|
|
315
|
+
**{**dem_opts, **parallel_kwargs}
|
|
316
|
+
)
|
|
317
|
+
return ('dem', dem)
|
|
318
|
+
|
|
319
|
+
with ThreadPoolExecutor(max_workers=4) as executor:
|
|
320
|
+
futures = [
|
|
321
|
+
executor.submit(download_land_cover),
|
|
322
|
+
executor.submit(download_building),
|
|
323
|
+
executor.submit(download_canopy),
|
|
324
|
+
executor.submit(download_dem),
|
|
325
|
+
]
|
|
326
|
+
completed_count = 0
|
|
327
|
+
for future in as_completed(futures):
|
|
328
|
+
try:
|
|
329
|
+
key, value = future.result()
|
|
330
|
+
results[key] = value
|
|
331
|
+
completed_count += 1
|
|
332
|
+
print(f" ✓ {key.replace('_', ' ').title()} complete ({completed_count}/4)")
|
|
333
|
+
except Exception as e:
|
|
334
|
+
logger.error("Parallel download failed: %s", e)
|
|
335
|
+
httpx_logger.setLevel(original_httpx_level) # Restore before raising
|
|
336
|
+
raise
|
|
337
|
+
|
|
338
|
+
# Restore httpx logging level
|
|
339
|
+
httpx_logger.setLevel(original_httpx_level)
|
|
340
|
+
|
|
341
|
+
print("-"*60)
|
|
342
|
+
print("All downloads complete!")
|
|
343
|
+
print("="*60 + "\n")
|
|
344
|
+
|
|
345
|
+
land_cover_grid, lc_src_effective = results['land_cover']
|
|
346
|
+
bh, bmin, bid, building_gdf_out = results['building']
|
|
347
|
+
canopy_top, canopy_bottom = results['canopy']
|
|
348
|
+
dem = results['dem']
|
|
349
|
+
|
|
350
|
+
return land_cover_grid, bh, bmin, bid, building_gdf_out, canopy_top, canopy_bottom, dem, lc_src_effective
|
|
351
|
+
|
|
352
|
+
def _run_parallel_downloads_static_canopy(
|
|
353
|
+
self, cfg, land_strategy, build_strategy, dem_strategy,
|
|
354
|
+
building_gdf, terrain_gdf, kwargs
|
|
355
|
+
):
|
|
356
|
+
"""
|
|
357
|
+
Run land_cover, building, and dem downloads in parallel.
|
|
358
|
+
Canopy (Static mode) will be run sequentially after, as it needs land_cover_grid.
|
|
359
|
+
"""
|
|
360
|
+
import logging
|
|
361
|
+
logger = get_logger(__name__)
|
|
362
|
+
|
|
363
|
+
# Print clean header for parallel mode
|
|
364
|
+
print("\n" + "="*60)
|
|
365
|
+
print("Downloading data in parallel mode (3 concurrent + 1 deferred)")
|
|
366
|
+
print("="*60)
|
|
367
|
+
print(f" • Land Cover: {cfg.land_cover_source}")
|
|
368
|
+
print(f" • Building: {cfg.building_source}")
|
|
369
|
+
print(f" • DEM: {cfg.dem_source}")
|
|
370
|
+
print(f" • Canopy: {cfg.canopy_height_source} (deferred)")
|
|
371
|
+
print("-"*60)
|
|
372
|
+
print("Downloading... (this may take a moment)")
|
|
373
|
+
|
|
374
|
+
results = {}
|
|
375
|
+
|
|
376
|
+
# Disable gridvis and verbose prints in parallel mode
|
|
377
|
+
parallel_kwargs = {**kwargs, 'gridvis': False, 'print_class_info': False, 'quiet': True}
|
|
378
|
+
lc_opts = {**cfg.land_cover_options, 'gridvis': False, 'print_class_info': False, 'quiet': True}
|
|
379
|
+
bld_opts = {**cfg.building_options, 'gridvis': False, 'quiet': True}
|
|
380
|
+
dem_opts = {**cfg.dem_options, 'gridvis': False, 'quiet': True}
|
|
381
|
+
|
|
382
|
+
# Suppress httpx verbose logging during parallel downloads
|
|
383
|
+
httpx_logger = logging.getLogger("httpx")
|
|
384
|
+
original_httpx_level = httpx_logger.level
|
|
385
|
+
httpx_logger.setLevel(logging.WARNING)
|
|
386
|
+
|
|
387
|
+
def download_land_cover():
|
|
388
|
+
grid = land_strategy.build_grid(
|
|
389
|
+
cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
|
|
390
|
+
**{**lc_opts, **parallel_kwargs}
|
|
391
|
+
)
|
|
392
|
+
try:
|
|
393
|
+
from .grids import get_last_effective_land_cover_source
|
|
394
|
+
effective = get_last_effective_land_cover_source() or cfg.land_cover_source
|
|
395
|
+
except Exception:
|
|
396
|
+
effective = cfg.land_cover_source
|
|
397
|
+
return ('land_cover', (grid, effective))
|
|
398
|
+
|
|
399
|
+
def download_building():
|
|
400
|
+
bh, bmin, bid, gdf_out = build_strategy.build_grids(
|
|
401
|
+
cfg.rectangle_vertices, cfg.meshsize, cfg.output_dir,
|
|
402
|
+
building_gdf=building_gdf,
|
|
403
|
+
**{**bld_opts, **parallel_kwargs}
|
|
404
|
+
)
|
|
405
|
+
return ('building', (bh, bmin, bid, gdf_out))
|
|
406
|
+
|
|
407
|
+
def download_dem():
|
|
408
|
+
dem = dem_strategy.build_grid(
|
|
409
|
+
cfg.rectangle_vertices, cfg.meshsize, None, cfg.output_dir,
|
|
410
|
+
terrain_gdf=terrain_gdf,
|
|
411
|
+
**{**dem_opts, **parallel_kwargs}
|
|
412
|
+
)
|
|
413
|
+
return ('dem', dem)
|
|
414
|
+
|
|
415
|
+
with ThreadPoolExecutor(max_workers=3) as executor:
|
|
416
|
+
futures = [
|
|
417
|
+
executor.submit(download_land_cover),
|
|
418
|
+
executor.submit(download_building),
|
|
419
|
+
executor.submit(download_dem),
|
|
420
|
+
]
|
|
421
|
+
completed_count = 0
|
|
422
|
+
for future in as_completed(futures):
|
|
423
|
+
try:
|
|
424
|
+
key, value = future.result()
|
|
425
|
+
results[key] = value
|
|
426
|
+
completed_count += 1
|
|
427
|
+
print(f" ✓ {key.replace('_', ' ').title()} complete ({completed_count}/3)")
|
|
428
|
+
except Exception as e:
|
|
429
|
+
logger.error("Parallel download failed: %s", e)
|
|
430
|
+
httpx_logger.setLevel(original_httpx_level)
|
|
431
|
+
raise
|
|
432
|
+
|
|
433
|
+
# Restore httpx logging level
|
|
434
|
+
httpx_logger.setLevel(original_httpx_level)
|
|
435
|
+
|
|
436
|
+
print("-"*60)
|
|
437
|
+
print("Parallel downloads complete! Processing canopy...")
|
|
438
|
+
|
|
439
|
+
land_cover_grid, lc_src_effective = results['land_cover']
|
|
440
|
+
bh, bmin, bid, building_gdf_out = results['building']
|
|
441
|
+
dem = results['dem']
|
|
442
|
+
|
|
443
|
+
return land_cover_grid, bh, bmin, bid, building_gdf_out, dem, lc_src_effective
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
class LandCoverSourceStrategy: # ABC simplified to avoid dependency in split
|
|
447
|
+
def build_grid(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs): # pragma: no cover - interface
|
|
448
|
+
raise NotImplementedError
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
class DefaultLandCoverStrategy(LandCoverSourceStrategy):
|
|
452
|
+
def __init__(self, source: str) -> None:
|
|
453
|
+
self.source = source
|
|
454
|
+
|
|
455
|
+
def build_grid(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs):
|
|
456
|
+
return get_land_cover_grid(rectangle_vertices, meshsize, self.source, output_dir, **kwargs)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
class LandCoverSourceFactory:
|
|
460
|
+
@staticmethod
|
|
461
|
+
def create(source: str) -> LandCoverSourceStrategy:
|
|
462
|
+
return DefaultLandCoverStrategy(source)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
class BuildingSourceStrategy: # ABC simplified
|
|
466
|
+
def build_grids(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs): # pragma: no cover - interface
|
|
467
|
+
raise NotImplementedError
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
class DefaultBuildingSourceStrategy(BuildingSourceStrategy):
|
|
471
|
+
def __init__(self, source: str) -> None:
|
|
472
|
+
self.source = source
|
|
473
|
+
|
|
474
|
+
def build_grids(self, rectangle_vertices, meshsize: float, output_dir: str, **kwargs):
|
|
475
|
+
return get_building_height_grid(rectangle_vertices, meshsize, self.source, output_dir, **kwargs)
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
class BuildingSourceFactory:
|
|
479
|
+
@staticmethod
|
|
480
|
+
def create(source: str) -> BuildingSourceStrategy:
|
|
481
|
+
return DefaultBuildingSourceStrategy(source)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
class CanopySourceStrategy: # ABC simplified
|
|
485
|
+
def build_grids(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs): # pragma: no cover
|
|
486
|
+
raise NotImplementedError
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class StaticCanopyStrategy(CanopySourceStrategy):
|
|
490
|
+
def __init__(self, cfg: PipelineConfig) -> None:
|
|
491
|
+
self.cfg = cfg
|
|
492
|
+
|
|
493
|
+
def build_grids(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
|
|
494
|
+
canopy_top = np.zeros_like(land_cover_grid, dtype=float)
|
|
495
|
+
static_h = self.cfg.static_tree_height if self.cfg.static_tree_height is not None else kwargs.get("static_tree_height", 10.0)
|
|
496
|
+
from ..utils.lc import get_land_cover_classes
|
|
497
|
+
_classes = get_land_cover_classes(self.cfg.land_cover_source)
|
|
498
|
+
_class_to_int = {name: i for i, name in enumerate(_classes.values())}
|
|
499
|
+
_tree_labels = ["Tree", "Trees", "Tree Canopy"]
|
|
500
|
+
_tree_idx = [_class_to_int[label] for label in _tree_labels if label in _class_to_int]
|
|
501
|
+
tree_mask = np.isin(land_cover_grid, _tree_idx) if _tree_idx else np.zeros_like(land_cover_grid, dtype=bool)
|
|
502
|
+
canopy_top[tree_mask] = static_h
|
|
503
|
+
tr = self.cfg.trunk_height_ratio if self.cfg.trunk_height_ratio is not None else (11.76 / 19.98)
|
|
504
|
+
canopy_bottom = canopy_top * float(tr)
|
|
505
|
+
return canopy_top, canopy_bottom
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class SourceCanopyStrategy(CanopySourceStrategy):
|
|
509
|
+
def __init__(self, source: str) -> None:
|
|
510
|
+
self.source = source
|
|
511
|
+
|
|
512
|
+
def build_grids(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
|
|
513
|
+
# Provide land_cover_like for graceful fallback sizing without EE
|
|
514
|
+
return get_canopy_height_grid(
|
|
515
|
+
rectangle_vertices,
|
|
516
|
+
meshsize,
|
|
517
|
+
self.source,
|
|
518
|
+
output_dir,
|
|
519
|
+
land_cover_like=land_cover_grid,
|
|
520
|
+
**kwargs,
|
|
521
|
+
)
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
class CanopySourceFactory:
|
|
525
|
+
@staticmethod
|
|
526
|
+
def create(source: str, cfg: PipelineConfig) -> CanopySourceStrategy:
|
|
527
|
+
if source == "Static":
|
|
528
|
+
return StaticCanopyStrategy(cfg)
|
|
529
|
+
return SourceCanopyStrategy(source)
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
class DemSourceStrategy: # ABC simplified
|
|
533
|
+
def build_grid(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs): # pragma: no cover
|
|
534
|
+
raise NotImplementedError
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
class FlatDemStrategy(DemSourceStrategy):
|
|
538
|
+
def build_grid(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
|
|
539
|
+
# Compute shape from rectangle_vertices if land_cover_grid is None
|
|
540
|
+
if land_cover_grid is None:
|
|
541
|
+
from ..geoprocessor.raster.core import compute_grid_shape
|
|
542
|
+
grid_shape = compute_grid_shape(rectangle_vertices, meshsize)
|
|
543
|
+
return np.zeros(grid_shape, dtype=float)
|
|
544
|
+
return np.zeros_like(land_cover_grid)
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
class SourceDemStrategy(DemSourceStrategy):
|
|
548
|
+
def __init__(self, source: str) -> None:
|
|
549
|
+
self.source = source
|
|
550
|
+
|
|
551
|
+
def build_grid(self, rectangle_vertices, meshsize: float, land_cover_grid: np.ndarray, output_dir: str, **kwargs):
|
|
552
|
+
terrain_gdf = kwargs.get("terrain_gdf")
|
|
553
|
+
if terrain_gdf is not None:
|
|
554
|
+
from ..geoprocessor.raster import create_dem_grid_from_gdf_polygon
|
|
555
|
+
return create_dem_grid_from_gdf_polygon(terrain_gdf, meshsize, rectangle_vertices)
|
|
556
|
+
try:
|
|
557
|
+
return get_dem_grid(rectangle_vertices, meshsize, self.source, output_dir, **kwargs)
|
|
558
|
+
except Exception as e:
|
|
559
|
+
# Fallback to flat DEM if source fails or unsupported
|
|
560
|
+
logger = get_logger(__name__)
|
|
561
|
+
logger.warning("DEM source '%s' failed (%s). Falling back to flat DEM.", self.source, e)
|
|
562
|
+
# Compute shape from rectangle_vertices if land_cover_grid is None
|
|
563
|
+
if land_cover_grid is None:
|
|
564
|
+
from ..geoprocessor.raster.core import compute_grid_shape
|
|
565
|
+
grid_shape = compute_grid_shape(rectangle_vertices, meshsize)
|
|
566
|
+
return np.zeros(grid_shape, dtype=float)
|
|
567
|
+
return np.zeros_like(land_cover_grid)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
class DemSourceFactory:
|
|
571
|
+
@staticmethod
|
|
572
|
+
def create(source: str) -> DemSourceStrategy:
|
|
573
|
+
# Normalize and auto-fallback: None/"none" -> Flat
|
|
574
|
+
try:
|
|
575
|
+
src_norm = (source or "").strip().lower()
|
|
576
|
+
except Exception:
|
|
577
|
+
src_norm = ""
|
|
578
|
+
if (not source) or (src_norm in {"flat", "none", "null"}):
|
|
579
|
+
return FlatDemStrategy()
|
|
580
|
+
return SourceDemStrategy(source)
|
|
581
|
+
|
|
582
|
+
|