maps4fs 2.8.9__py3-none-any.whl → 2.9.37__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.
- maps4fs/generator/component/background.py +118 -35
- maps4fs/generator/component/base/component_mesh.py +39 -13
- maps4fs/generator/component/building.py +706 -0
- maps4fs/generator/component/config.py +5 -0
- maps4fs/generator/component/dem.py +7 -0
- maps4fs/generator/component/grle.py +66 -0
- maps4fs/generator/component/i3d.py +63 -5
- maps4fs/generator/component/layer.py +19 -0
- maps4fs/generator/component/road.py +648 -0
- maps4fs/generator/component/satellite.py +3 -0
- maps4fs/generator/component/texture.py +89 -50
- maps4fs/generator/config.py +79 -11
- maps4fs/generator/game.py +64 -3
- maps4fs/generator/map.py +73 -39
- maps4fs/generator/monitor.py +118 -0
- maps4fs/generator/settings.py +18 -1
- maps4fs/generator/statistics.py +10 -0
- maps4fs/generator/utils.py +26 -1
- maps4fs/logger.py +2 -1
- {maps4fs-2.8.9.dist-info → maps4fs-2.9.37.dist-info}/METADATA +6 -4
- maps4fs-2.9.37.dist-info/RECORD +32 -0
- maps4fs-2.9.37.dist-info/licenses/LICENSE.md +416 -0
- maps4fs-2.8.9.dist-info/RECORD +0 -29
- maps4fs-2.8.9.dist-info/licenses/LICENSE.md +0 -651
- {maps4fs-2.8.9.dist-info → maps4fs-2.9.37.dist-info}/WHEEL +0 -0
- {maps4fs-2.8.9.dist-info → maps4fs-2.9.37.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,706 @@
|
|
|
1
|
+
"""Component for map buildings processing and generation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from typing import NamedTuple
|
|
6
|
+
from xml.etree import ElementTree as ET
|
|
7
|
+
|
|
8
|
+
import cv2
|
|
9
|
+
import numpy as np
|
|
10
|
+
from tqdm import tqdm
|
|
11
|
+
|
|
12
|
+
from maps4fs.generator.component.i3d import I3d
|
|
13
|
+
from maps4fs.generator.settings import Parameters
|
|
14
|
+
from maps4fs.generator.utils import get_region_by_coordinates
|
|
15
|
+
|
|
16
|
+
BUILDINGS_STARTING_NODE_ID = 10000
|
|
17
|
+
DEFAULT_HEIGHT = 200
|
|
18
|
+
AUTO_REGION = "auto"
|
|
19
|
+
ALL_REGIONS = "all"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
AREA_TYPES = {
|
|
23
|
+
"residential": 10,
|
|
24
|
+
"commercial": 20,
|
|
25
|
+
"industrial": 30,
|
|
26
|
+
"retail": 40,
|
|
27
|
+
"farmyard": 50,
|
|
28
|
+
"religious": 60,
|
|
29
|
+
"recreation": 70,
|
|
30
|
+
}
|
|
31
|
+
PIXEL_TYPES = {v: k for k, v in AREA_TYPES.items()}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class BuildingEntry(NamedTuple):
|
|
35
|
+
"""Data structure for a building entry in the buildings schema."""
|
|
36
|
+
|
|
37
|
+
file: str
|
|
38
|
+
name: str
|
|
39
|
+
width: float
|
|
40
|
+
depth: float
|
|
41
|
+
categories: list[str]
|
|
42
|
+
regions: list[str]
|
|
43
|
+
type: str | None = None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class BuildingEntryCollection:
|
|
47
|
+
"""Collection of building entries with efficient lookup capabilities."""
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self, building_entries: list[BuildingEntry], region: str, ignore_region: bool = False
|
|
51
|
+
):
|
|
52
|
+
"""Initialize the collection with a list of building entries for a specific region.
|
|
53
|
+
|
|
54
|
+
Arguments:
|
|
55
|
+
building_entries (list[BuildingEntry]): List of building entries to manage
|
|
56
|
+
region (str): The region for this collection (filters entries to this region only)
|
|
57
|
+
ignore_region (bool): If True, ignore region filtering and use all entries
|
|
58
|
+
"""
|
|
59
|
+
self.region = region
|
|
60
|
+
self.ignore_region = ignore_region
|
|
61
|
+
|
|
62
|
+
# Filter entries based on ignore_region flag
|
|
63
|
+
if ignore_region:
|
|
64
|
+
self.entries = building_entries # Use all entries regardless of region
|
|
65
|
+
else:
|
|
66
|
+
self.entries = [entry for entry in building_entries if region in entry.regions]
|
|
67
|
+
|
|
68
|
+
# Create indices for faster lookup
|
|
69
|
+
self._create_indices()
|
|
70
|
+
|
|
71
|
+
def _create_indices(self) -> None:
|
|
72
|
+
"""Create indexed dictionaries for faster lookups."""
|
|
73
|
+
self.by_category: dict[str, list[BuildingEntry]] = {}
|
|
74
|
+
|
|
75
|
+
for entry in self.entries:
|
|
76
|
+
# Index by each category (all entries are already filtered by region)
|
|
77
|
+
for category in entry.categories:
|
|
78
|
+
if category not in self.by_category:
|
|
79
|
+
self.by_category[category] = []
|
|
80
|
+
self.by_category[category].append(entry)
|
|
81
|
+
|
|
82
|
+
def find_best_match(
|
|
83
|
+
self,
|
|
84
|
+
category: str,
|
|
85
|
+
width: float | None = None,
|
|
86
|
+
depth: float | None = None,
|
|
87
|
+
tolerance: float = 0.3,
|
|
88
|
+
) -> BuildingEntry | None:
|
|
89
|
+
"""Find the best matching building entry based on criteria.
|
|
90
|
+
Entries are filtered by region during initialization unless ignore_region is True.
|
|
91
|
+
|
|
92
|
+
Arguments:
|
|
93
|
+
category (str): Required building category
|
|
94
|
+
width (float | None): Desired width (optional)
|
|
95
|
+
depth (float | None): Desired depth (optional)
|
|
96
|
+
tolerance (float): Size tolerance factor (0.3 = 30% tolerance)
|
|
97
|
+
|
|
98
|
+
Returns:
|
|
99
|
+
BuildingEntry | None: Best matching entry or None if no suitable match found
|
|
100
|
+
"""
|
|
101
|
+
# Start with buildings of the required category (filtered by region unless ignore_region is True)
|
|
102
|
+
candidates = self.by_category.get(category, [])
|
|
103
|
+
if not candidates:
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
# Score each candidate
|
|
107
|
+
scored_candidates = []
|
|
108
|
+
for entry in candidates:
|
|
109
|
+
score = self._calculate_match_score(entry, category, width, depth, tolerance)
|
|
110
|
+
if score > 0: # Only consider viable matches
|
|
111
|
+
scored_candidates.append((score, entry))
|
|
112
|
+
|
|
113
|
+
if not scored_candidates:
|
|
114
|
+
return None
|
|
115
|
+
|
|
116
|
+
# Return the highest scoring match
|
|
117
|
+
scored_candidates.sort(key=lambda x: x[0], reverse=True)
|
|
118
|
+
return scored_candidates[0][1]
|
|
119
|
+
|
|
120
|
+
def find_best_match_with_orientation(
|
|
121
|
+
self,
|
|
122
|
+
category: str,
|
|
123
|
+
width: float | None = None,
|
|
124
|
+
depth: float | None = None,
|
|
125
|
+
tolerance: float = 0.3,
|
|
126
|
+
) -> tuple[BuildingEntry | None, bool]:
|
|
127
|
+
"""Find the best matching building entry and determine if rotation is needed.
|
|
128
|
+
|
|
129
|
+
Arguments:
|
|
130
|
+
category (str): Required building category
|
|
131
|
+
width (float | None): Desired width (optional)
|
|
132
|
+
depth (float | None): Desired depth (optional)
|
|
133
|
+
tolerance (float): Size tolerance factor (0.3 = 30% tolerance)
|
|
134
|
+
|
|
135
|
+
Returns:
|
|
136
|
+
tuple[BuildingEntry | None, bool]: Best matching entry and whether it needs 90° rotation
|
|
137
|
+
"""
|
|
138
|
+
# Start with buildings of the required category
|
|
139
|
+
candidates = self.by_category.get(category, [])
|
|
140
|
+
if not candidates:
|
|
141
|
+
return None, False
|
|
142
|
+
|
|
143
|
+
# Score each candidate and track orientation
|
|
144
|
+
scored_candidates = []
|
|
145
|
+
for entry in candidates:
|
|
146
|
+
score, needs_rotation = self._calculate_match_score_with_orientation(
|
|
147
|
+
entry, category, width, depth, tolerance
|
|
148
|
+
)
|
|
149
|
+
if score > 0: # Only consider viable matches
|
|
150
|
+
scored_candidates.append((score, entry, needs_rotation))
|
|
151
|
+
|
|
152
|
+
if not scored_candidates:
|
|
153
|
+
return None, False
|
|
154
|
+
|
|
155
|
+
# Return the highest scoring match with its orientation info
|
|
156
|
+
scored_candidates.sort(key=lambda x: x[0], reverse=True)
|
|
157
|
+
_, best_entry, needs_rotation = scored_candidates[0]
|
|
158
|
+
return best_entry, needs_rotation
|
|
159
|
+
|
|
160
|
+
def _calculate_match_score_with_orientation(
|
|
161
|
+
self,
|
|
162
|
+
entry: BuildingEntry,
|
|
163
|
+
category: str,
|
|
164
|
+
width: float | None,
|
|
165
|
+
depth: float | None,
|
|
166
|
+
tolerance: float,
|
|
167
|
+
) -> tuple[float, bool]:
|
|
168
|
+
"""Calculate a match score and determine orientation for a building entry.
|
|
169
|
+
|
|
170
|
+
Returns:
|
|
171
|
+
tuple[float, bool]: Match score and whether 90° rotation is needed
|
|
172
|
+
"""
|
|
173
|
+
score = 0.0
|
|
174
|
+
|
|
175
|
+
# Category match (required) - base score
|
|
176
|
+
if category in entry.categories:
|
|
177
|
+
score = 100.0
|
|
178
|
+
else:
|
|
179
|
+
return 0.0, False # Category mismatch = no match
|
|
180
|
+
|
|
181
|
+
# Size matching (if dimensions are provided)
|
|
182
|
+
if width is not None and depth is not None:
|
|
183
|
+
# Calculate how well the dimensions match (considering both orientations)
|
|
184
|
+
size_score1 = self._calculate_size_match(
|
|
185
|
+
entry.width, entry.depth, width, depth, tolerance
|
|
186
|
+
)
|
|
187
|
+
size_score2 = self._calculate_size_match(
|
|
188
|
+
entry.width, entry.depth, depth, width, tolerance
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Determine which orientation is better
|
|
192
|
+
if size_score1 >= size_score2:
|
|
193
|
+
# Original orientation is better
|
|
194
|
+
if size_score1 > 0:
|
|
195
|
+
score += size_score1 * 80.0
|
|
196
|
+
return score, False
|
|
197
|
+
return 0.0, False
|
|
198
|
+
if size_score2 > 0:
|
|
199
|
+
score += size_score2 * 80.0
|
|
200
|
+
return score, True
|
|
201
|
+
return 0.0, False
|
|
202
|
+
|
|
203
|
+
return score, False
|
|
204
|
+
|
|
205
|
+
def _calculate_match_score(
|
|
206
|
+
self,
|
|
207
|
+
entry: BuildingEntry,
|
|
208
|
+
category: str,
|
|
209
|
+
width: float | None,
|
|
210
|
+
depth: float | None,
|
|
211
|
+
tolerance: float,
|
|
212
|
+
) -> float:
|
|
213
|
+
"""Calculate a match score for a building entry.
|
|
214
|
+
Region is matched during initialization unless ignore_region is True.
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
float: Match score (higher is better, 0 means no match)
|
|
218
|
+
"""
|
|
219
|
+
score = 0.0
|
|
220
|
+
|
|
221
|
+
# Category match (required) - base score
|
|
222
|
+
if category in entry.categories:
|
|
223
|
+
score = 100.0
|
|
224
|
+
else:
|
|
225
|
+
return 0.0 # Category mismatch = no match
|
|
226
|
+
|
|
227
|
+
# Size matching (if dimensions are provided)
|
|
228
|
+
if width is not None and depth is not None:
|
|
229
|
+
# Calculate how well the dimensions match (considering both orientations)
|
|
230
|
+
size_score1 = self._calculate_size_match(
|
|
231
|
+
entry.width, entry.depth, width, depth, tolerance
|
|
232
|
+
)
|
|
233
|
+
size_score2 = self._calculate_size_match(
|
|
234
|
+
entry.width, entry.depth, depth, width, tolerance
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
# Use the better orientation
|
|
238
|
+
size_score = max(size_score1, size_score2)
|
|
239
|
+
|
|
240
|
+
if size_score > 0:
|
|
241
|
+
score += (
|
|
242
|
+
size_score * 80.0
|
|
243
|
+
) # Size match contributes up to 80 points (increased since no region bonus)
|
|
244
|
+
else:
|
|
245
|
+
return 0.0 # Size too different = no match
|
|
246
|
+
|
|
247
|
+
return score
|
|
248
|
+
|
|
249
|
+
def _calculate_size_match(
|
|
250
|
+
self,
|
|
251
|
+
entry_width: float,
|
|
252
|
+
entry_depth: float,
|
|
253
|
+
target_width: float,
|
|
254
|
+
target_depth: float,
|
|
255
|
+
tolerance: float,
|
|
256
|
+
) -> float:
|
|
257
|
+
"""Calculate how well building dimensions match target dimensions.
|
|
258
|
+
|
|
259
|
+
Returns:
|
|
260
|
+
float: Size match score between 0 and 1 (1 = perfect match)
|
|
261
|
+
"""
|
|
262
|
+
width_ratio = min(entry_width, target_width) / max(entry_width, target_width)
|
|
263
|
+
depth_ratio = min(entry_depth, target_depth) / max(entry_depth, target_depth)
|
|
264
|
+
|
|
265
|
+
# Check if both dimensions are within tolerance
|
|
266
|
+
if width_ratio < (1 - tolerance) or depth_ratio < (1 - tolerance):
|
|
267
|
+
return 0.0
|
|
268
|
+
|
|
269
|
+
# Calculate combined size score (average of both dimension matches)
|
|
270
|
+
return (width_ratio + depth_ratio) / 2.0
|
|
271
|
+
|
|
272
|
+
def get_available_categories(self) -> list[str]:
|
|
273
|
+
"""Get list of available building categories for this collection."""
|
|
274
|
+
return list(self.by_category.keys())
|
|
275
|
+
|
|
276
|
+
def filter_by_category(self, category: str) -> list[BuildingEntry]:
|
|
277
|
+
"""Get all buildings of a specific category (filtered by region unless ignore_region is True)."""
|
|
278
|
+
return self.by_category.get(category, [])
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def building_category_type_to_pixel(building_category: str) -> int | None:
|
|
282
|
+
"""Returns the pixel value representation of the building category.
|
|
283
|
+
If not found, returns None.
|
|
284
|
+
|
|
285
|
+
Arguments:
|
|
286
|
+
building_category (str | None): The building category type as a string.
|
|
287
|
+
|
|
288
|
+
Returns:
|
|
289
|
+
int | None: pixel value of the building category, or None if not found.
|
|
290
|
+
"""
|
|
291
|
+
return AREA_TYPES.get(building_category)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def pixel_value_to_building_category_type(pixel_value: int) -> str:
|
|
295
|
+
"""Returns the building category type representation of the pixel value.
|
|
296
|
+
If not found, returns "residential".
|
|
297
|
+
|
|
298
|
+
Arguments:
|
|
299
|
+
pixel_value (int | None): The pixel value to look up the building category for.
|
|
300
|
+
|
|
301
|
+
Returns:
|
|
302
|
+
str: building category of the pixel value, or "residential" if not found.
|
|
303
|
+
"""
|
|
304
|
+
return PIXEL_TYPES.get(pixel_value, "residential")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
class Building(I3d):
|
|
308
|
+
"""Component for map buildings processing and generation.
|
|
309
|
+
|
|
310
|
+
Arguments:
|
|
311
|
+
game (Game): The game instance for which the map is generated.
|
|
312
|
+
coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
|
|
313
|
+
map_size (int): The size of the map in pixels.
|
|
314
|
+
map_rotated_size (int): The size of the map in pixels after rotation.
|
|
315
|
+
rotation (int): The rotation angle of the map.
|
|
316
|
+
map_directory (str): The directory where the map files are stored.
|
|
317
|
+
logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
|
|
318
|
+
info, warning. If not provided, default logging will be used.
|
|
319
|
+
"""
|
|
320
|
+
|
|
321
|
+
def preprocess(self) -> None:
|
|
322
|
+
"""Preprocess and prepare buildings schema and buildings map image."""
|
|
323
|
+
try:
|
|
324
|
+
buildings_schema_path = self.game.buildings_schema
|
|
325
|
+
except ValueError as e:
|
|
326
|
+
self.logger.warning("The game does not support buildings schema: %s", e)
|
|
327
|
+
return
|
|
328
|
+
|
|
329
|
+
custom_buildings_schema = self.map.buildings_custom_schema
|
|
330
|
+
if not custom_buildings_schema:
|
|
331
|
+
if not os.path.isfile(buildings_schema_path):
|
|
332
|
+
self.logger.warning(
|
|
333
|
+
"Buildings schema file not found at path: %s. Skipping buildings generation.",
|
|
334
|
+
buildings_schema_path,
|
|
335
|
+
)
|
|
336
|
+
return
|
|
337
|
+
|
|
338
|
+
try:
|
|
339
|
+
with open(buildings_schema_path, "r", encoding="utf-8") as f:
|
|
340
|
+
buildings_schema = json.load(f)
|
|
341
|
+
|
|
342
|
+
self.buildings_schema = buildings_schema
|
|
343
|
+
|
|
344
|
+
except Exception as e:
|
|
345
|
+
self.logger.warning(
|
|
346
|
+
"Failed to load buildings schema from path: %s with error: %s. Skipping buildings generation.",
|
|
347
|
+
buildings_schema_path,
|
|
348
|
+
e,
|
|
349
|
+
)
|
|
350
|
+
else:
|
|
351
|
+
self.buildings_schema = custom_buildings_schema
|
|
352
|
+
|
|
353
|
+
self.logger.info(
|
|
354
|
+
"Buildings schema loaded successfully with %d objects.", len(self.buildings_schema)
|
|
355
|
+
)
|
|
356
|
+
|
|
357
|
+
self.xml_path = self.game.i3d_file_path(self.map_directory)
|
|
358
|
+
|
|
359
|
+
buildings_directory = os.path.join(self.map.map_directory, "buildings")
|
|
360
|
+
self.buildings_map_path = os.path.join(buildings_directory, "building_categories.png")
|
|
361
|
+
os.makedirs(buildings_directory, exist_ok=True)
|
|
362
|
+
|
|
363
|
+
texture_component = self.map.get_texture_component()
|
|
364
|
+
if not texture_component:
|
|
365
|
+
self.logger.warning("Texture component not found in the map.")
|
|
366
|
+
return
|
|
367
|
+
|
|
368
|
+
# Creating empty single-channel image for building categories.
|
|
369
|
+
buildings_map_image = np.zeros((self.map.size, self.map.size), dtype=np.uint8)
|
|
370
|
+
|
|
371
|
+
for layer in texture_component.get_building_category_layers():
|
|
372
|
+
self.logger.debug(
|
|
373
|
+
"Processing building category layer: %s (%s)",
|
|
374
|
+
layer.name,
|
|
375
|
+
layer.building_category,
|
|
376
|
+
)
|
|
377
|
+
pixel_value = building_category_type_to_pixel(layer.building_category) # type: ignore
|
|
378
|
+
if pixel_value is None:
|
|
379
|
+
self.logger.warning(
|
|
380
|
+
"Unknown building category '%s' for layer '%s'. Skipping.",
|
|
381
|
+
layer.building_category,
|
|
382
|
+
layer.name,
|
|
383
|
+
)
|
|
384
|
+
continue
|
|
385
|
+
|
|
386
|
+
layer_path = layer.path(self.game.weights_dir_path(self.map.map_directory))
|
|
387
|
+
if not layer_path or not os.path.isfile(layer_path):
|
|
388
|
+
self.logger.warning("Layer texture file not found: %s. Skipping.", layer_path)
|
|
389
|
+
continue
|
|
390
|
+
|
|
391
|
+
layer_image = cv2.imread(layer_path, cv2.IMREAD_UNCHANGED)
|
|
392
|
+
if layer_image is None:
|
|
393
|
+
self.logger.warning("Failed to read layer image: %s. Skipping.", layer_path)
|
|
394
|
+
continue
|
|
395
|
+
|
|
396
|
+
mask = layer_image > 0
|
|
397
|
+
buildings_map_image[mask] = pixel_value
|
|
398
|
+
|
|
399
|
+
# Save the buildings map image
|
|
400
|
+
cv2.imwrite(self.buildings_map_path, buildings_map_image)
|
|
401
|
+
self.logger.info("Building categories map saved to: %s", self.buildings_map_path)
|
|
402
|
+
|
|
403
|
+
building_entries = []
|
|
404
|
+
for building_entry in self.buildings_schema:
|
|
405
|
+
building = BuildingEntry(**building_entry)
|
|
406
|
+
building_entries.append(building)
|
|
407
|
+
|
|
408
|
+
ignore_region = False
|
|
409
|
+
region = ""
|
|
410
|
+
|
|
411
|
+
if self.map.building_settings.region == AUTO_REGION:
|
|
412
|
+
region = get_region_by_coordinates(self.coordinates)
|
|
413
|
+
elif self.map.building_settings.region == ALL_REGIONS:
|
|
414
|
+
ignore_region = True
|
|
415
|
+
region = "all" # Set a default region name for logging
|
|
416
|
+
else:
|
|
417
|
+
region = self.map.building_settings.region
|
|
418
|
+
|
|
419
|
+
self.buildings_collection = BuildingEntryCollection(building_entries, region, ignore_region)
|
|
420
|
+
|
|
421
|
+
if ignore_region:
|
|
422
|
+
self.logger.info(
|
|
423
|
+
"Buildings collection created with %d buildings ignoring region restrictions.",
|
|
424
|
+
len(self.buildings_collection.entries),
|
|
425
|
+
)
|
|
426
|
+
else:
|
|
427
|
+
self.logger.info(
|
|
428
|
+
"Buildings collection created with %d buildings for region '%s'.",
|
|
429
|
+
len(self.buildings_collection.entries),
|
|
430
|
+
region,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def process(self) -> None:
|
|
434
|
+
"""Process and place buildings on the map."""
|
|
435
|
+
try:
|
|
436
|
+
self.add_buildings()
|
|
437
|
+
except Exception as e:
|
|
438
|
+
self.logger.warning("An error occurred during buildings processing: %s", e)
|
|
439
|
+
|
|
440
|
+
# pylint: disable=too-many-return-statements
|
|
441
|
+
def add_buildings(self) -> None:
|
|
442
|
+
"""Process and place buildings on the map based on the buildings map image and schema."""
|
|
443
|
+
if not hasattr(self, "buildings_map_path") or not os.path.isfile(self.buildings_map_path):
|
|
444
|
+
self.logger.warning(
|
|
445
|
+
"Buildings map path is not set or file does not exist. Skipping process step."
|
|
446
|
+
)
|
|
447
|
+
return
|
|
448
|
+
|
|
449
|
+
# Check if the collection contains any buildings.
|
|
450
|
+
if not self.buildings_collection.entries:
|
|
451
|
+
self.logger.warning(
|
|
452
|
+
"No buildings found in the collection. Buildings generation will be skipped.",
|
|
453
|
+
)
|
|
454
|
+
return
|
|
455
|
+
|
|
456
|
+
buildings_map_image = cv2.imread(self.buildings_map_path, cv2.IMREAD_UNCHANGED)
|
|
457
|
+
if buildings_map_image is None:
|
|
458
|
+
self.logger.warning("Failed to read buildings map image. Skipping process step.")
|
|
459
|
+
return
|
|
460
|
+
|
|
461
|
+
self.logger.debug("Buildings map categories file found, processing...")
|
|
462
|
+
|
|
463
|
+
buildings = self.get_infolayer_data(Parameters.TEXTURES, Parameters.BUILDINGS)
|
|
464
|
+
if not buildings:
|
|
465
|
+
self.logger.warning("Buildings data not found in textures info layer.")
|
|
466
|
+
return
|
|
467
|
+
|
|
468
|
+
self.logger.info("Found %d building entries to process.", len(buildings))
|
|
469
|
+
|
|
470
|
+
# Initialize tracking for XML modifications
|
|
471
|
+
tree = self.get_tree()
|
|
472
|
+
root = tree.getroot()
|
|
473
|
+
|
|
474
|
+
if root is None:
|
|
475
|
+
self.logger.warning("Failed to get root element from I3D XML tree.")
|
|
476
|
+
return
|
|
477
|
+
|
|
478
|
+
# Find the Scene element
|
|
479
|
+
scene_node = root.find(".//Scene")
|
|
480
|
+
if scene_node is None:
|
|
481
|
+
self.logger.warning("Scene element not found in I3D file.")
|
|
482
|
+
return
|
|
483
|
+
|
|
484
|
+
# Find or create the Files section
|
|
485
|
+
files_section = root.find("Files")
|
|
486
|
+
if files_section is None:
|
|
487
|
+
files_section = ET.SubElement(root, "Files")
|
|
488
|
+
|
|
489
|
+
# Find or create the buildings transform group in the scene
|
|
490
|
+
buildings_group = self._find_or_create_buildings_group(scene_node)
|
|
491
|
+
|
|
492
|
+
# Track used building files to avoid duplicates (file_path -> file_id mapping)
|
|
493
|
+
used_building_files = {}
|
|
494
|
+
file_id_counter = BUILDINGS_STARTING_NODE_ID
|
|
495
|
+
node_id_counter = BUILDINGS_STARTING_NODE_ID + 1000
|
|
496
|
+
|
|
497
|
+
not_resized_dem = self.get_not_resized_dem_with_foundations(allow_fallback=True)
|
|
498
|
+
if not_resized_dem is None:
|
|
499
|
+
self.logger.warning("Not resized DEM not found.")
|
|
500
|
+
return
|
|
501
|
+
|
|
502
|
+
for building in tqdm(buildings, desc="Placing buildings", unit="building"):
|
|
503
|
+
try:
|
|
504
|
+
fitted_building = self.fit_object_into_bounds(
|
|
505
|
+
polygon_points=building, angle=self.rotation
|
|
506
|
+
)
|
|
507
|
+
except ValueError as e:
|
|
508
|
+
self.logger.debug(
|
|
509
|
+
"Building could not be fitted into the map bounds with error: %s",
|
|
510
|
+
e,
|
|
511
|
+
)
|
|
512
|
+
continue
|
|
513
|
+
|
|
514
|
+
# 1. Identify the center point of the building polygon.
|
|
515
|
+
center_point = np.mean(fitted_building, axis=0).astype(int)
|
|
516
|
+
x, y = center_point
|
|
517
|
+
self.logger.debug("Center point of building polygon: %s", center_point)
|
|
518
|
+
|
|
519
|
+
pixel_value = buildings_map_image[y, x]
|
|
520
|
+
self.logger.debug("Pixel value at center point: %s", pixel_value)
|
|
521
|
+
|
|
522
|
+
category = pixel_value_to_building_category_type(pixel_value)
|
|
523
|
+
self.logger.debug("Building category at center point: %s", category)
|
|
524
|
+
|
|
525
|
+
# 2. Obtain building dimensions and rotation using minimum area bounding rectangle
|
|
526
|
+
polygon_np = self.polygon_points_to_np(fitted_building)
|
|
527
|
+
width, depth, rotation_angle = self._get_polygon_dimensions_and_rotation(polygon_np)
|
|
528
|
+
self.logger.debug(
|
|
529
|
+
"Building dimensions: width=%d, depth=%d, rotation=%d°",
|
|
530
|
+
width,
|
|
531
|
+
depth,
|
|
532
|
+
rotation_angle,
|
|
533
|
+
)
|
|
534
|
+
|
|
535
|
+
# 3. Find the best matching building from the collection and determine orientation
|
|
536
|
+
best_match, needs_rotation = self.buildings_collection.find_best_match_with_orientation(
|
|
537
|
+
category=category,
|
|
538
|
+
width=width,
|
|
539
|
+
depth=depth,
|
|
540
|
+
tolerance=self.map.building_settings.tolerance_factor,
|
|
541
|
+
)
|
|
542
|
+
|
|
543
|
+
if best_match:
|
|
544
|
+
self.logger.debug(
|
|
545
|
+
"Best building match: %s: %d x %d, needs_rotation: %s",
|
|
546
|
+
best_match.name,
|
|
547
|
+
best_match.width,
|
|
548
|
+
best_match.depth,
|
|
549
|
+
needs_rotation,
|
|
550
|
+
)
|
|
551
|
+
|
|
552
|
+
# Get world coordinates
|
|
553
|
+
x_center, y_center = self.top_left_coordinates_to_center(center_point)
|
|
554
|
+
try:
|
|
555
|
+
z = self.get_z_coordinate_from_dem(not_resized_dem, x, y)
|
|
556
|
+
except Exception as e:
|
|
557
|
+
self.logger.warning(
|
|
558
|
+
"Failed to get Z coordinate from DEM at (%d, %d) with error: %s. Using default height %d.",
|
|
559
|
+
x,
|
|
560
|
+
y,
|
|
561
|
+
e,
|
|
562
|
+
DEFAULT_HEIGHT,
|
|
563
|
+
)
|
|
564
|
+
z = DEFAULT_HEIGHT
|
|
565
|
+
|
|
566
|
+
# * Disabled for now, maybe re-enable later.
|
|
567
|
+
# Calculate scale factors to match the polygon size
|
|
568
|
+
# scale_width = width / best_match.width
|
|
569
|
+
# scale_depth = depth / best_match.depth
|
|
570
|
+
|
|
571
|
+
self.logger.debug(
|
|
572
|
+
"World coordinates for building: x=%.3f, y=%.3f, z=%.3f",
|
|
573
|
+
x_center,
|
|
574
|
+
y_center,
|
|
575
|
+
z,
|
|
576
|
+
)
|
|
577
|
+
# self.logger.debug(
|
|
578
|
+
# "Scale factors: width=%.4f, depth=%.4f",
|
|
579
|
+
# scale_width,
|
|
580
|
+
# scale_depth,
|
|
581
|
+
# )
|
|
582
|
+
|
|
583
|
+
# Add building file to Files section if not already present
|
|
584
|
+
file_id = None
|
|
585
|
+
if best_match.file not in used_building_files:
|
|
586
|
+
file_id = file_id_counter
|
|
587
|
+
file_element = ET.SubElement(files_section, "File")
|
|
588
|
+
file_element.set("fileId", str(file_id))
|
|
589
|
+
file_element.set("filename", best_match.file)
|
|
590
|
+
used_building_files[best_match.file] = file_id
|
|
591
|
+
file_id_counter += 1
|
|
592
|
+
else:
|
|
593
|
+
file_id = used_building_files[best_match.file]
|
|
594
|
+
|
|
595
|
+
# Adjust rotation if the building needs to be rotated 90 degrees
|
|
596
|
+
final_rotation = rotation_angle
|
|
597
|
+
if needs_rotation:
|
|
598
|
+
final_rotation = (rotation_angle + 90.0) % 360.0
|
|
599
|
+
self.logger.debug(
|
|
600
|
+
"Building needs 90° rotation: original=%.1f°, final=%.1f°",
|
|
601
|
+
rotation_angle,
|
|
602
|
+
final_rotation,
|
|
603
|
+
)
|
|
604
|
+
|
|
605
|
+
# Create building instance in the buildings group
|
|
606
|
+
building_node = ET.SubElement(buildings_group, "ReferenceNode")
|
|
607
|
+
building_node.set("name", f"{best_match.name}_{node_id_counter}")
|
|
608
|
+
building_node.set("translation", f"{x_center:.3f} {z:.3f} {y_center:.3f}")
|
|
609
|
+
building_node.set("rotation", f"0 {final_rotation:.3f} 0")
|
|
610
|
+
# building_node.set(
|
|
611
|
+
# "scale", f"{scale_width:.4f} 1.0 {scale_depth:.4f}"
|
|
612
|
+
# )
|
|
613
|
+
building_node.set("referenceId", str(file_id))
|
|
614
|
+
building_node.set("nodeId", str(node_id_counter))
|
|
615
|
+
|
|
616
|
+
node_id_counter += 1
|
|
617
|
+
|
|
618
|
+
else:
|
|
619
|
+
self.logger.debug(
|
|
620
|
+
"No suitable building found for category '%s' with dimensions %.2fx%.2f",
|
|
621
|
+
category,
|
|
622
|
+
width,
|
|
623
|
+
depth,
|
|
624
|
+
needs_rotation,
|
|
625
|
+
)
|
|
626
|
+
continue
|
|
627
|
+
|
|
628
|
+
added_buildings_count = node_id_counter - (BUILDINGS_STARTING_NODE_ID + 1000)
|
|
629
|
+
self.logger.info("Total buildings placed: %d of %d", added_buildings_count, len(buildings))
|
|
630
|
+
|
|
631
|
+
# Save the modified XML tree
|
|
632
|
+
self.save_tree(tree)
|
|
633
|
+
self.logger.info("Buildings placement completed and saved to map.i3d")
|
|
634
|
+
|
|
635
|
+
def _get_polygon_dimensions_and_rotation(
|
|
636
|
+
self, polygon_points: np.ndarray
|
|
637
|
+
) -> tuple[float, float, float]:
|
|
638
|
+
"""Calculate width, depth, and rotation angle of a polygon using minimum area bounding rectangle.
|
|
639
|
+
|
|
640
|
+
Arguments:
|
|
641
|
+
polygon_points (np.ndarray): Array of polygon points with shape (n, 2)
|
|
642
|
+
|
|
643
|
+
Returns:
|
|
644
|
+
tuple[float, float, float]: width, depth, and rotation angle in degrees
|
|
645
|
+
"""
|
|
646
|
+
# Convert to the format expected by cv2.minAreaRect (needs to be float32)
|
|
647
|
+
points = polygon_points.astype(np.float32)
|
|
648
|
+
|
|
649
|
+
# Find the minimum area bounding rectangle
|
|
650
|
+
rect = cv2.minAreaRect(points)
|
|
651
|
+
|
|
652
|
+
# rect contains: ((center_x, center_y), (width, height), angle)
|
|
653
|
+
(_, _), (width, height), angle = rect
|
|
654
|
+
|
|
655
|
+
# OpenCV's minAreaRect returns angle in range [-90, 0) for the longer side
|
|
656
|
+
# We need to convert this to a proper world rotation angle
|
|
657
|
+
|
|
658
|
+
# First, ensure width is the longer dimension
|
|
659
|
+
if width < height:
|
|
660
|
+
# Swap dimensions
|
|
661
|
+
width, height = height, width
|
|
662
|
+
# When we swap dimensions, we need to adjust the angle by 90 degrees
|
|
663
|
+
angle = angle + 90.0
|
|
664
|
+
|
|
665
|
+
# Convert OpenCV angle to world rotation angle
|
|
666
|
+
# OpenCV angle is measured from the horizontal axis, counter-clockwise
|
|
667
|
+
# But we want the angle in degrees for Y-axis rotation in 3D space
|
|
668
|
+
rotation_angle = -angle # Negative because 3D rotation is clockwise positive
|
|
669
|
+
|
|
670
|
+
# Normalize to [0, 360) range
|
|
671
|
+
while rotation_angle < 0:
|
|
672
|
+
rotation_angle += 360
|
|
673
|
+
while rotation_angle >= 360:
|
|
674
|
+
rotation_angle -= 360
|
|
675
|
+
|
|
676
|
+
return width, height, rotation_angle
|
|
677
|
+
|
|
678
|
+
def _find_or_create_buildings_group(self, scene_node: ET.Element) -> ET.Element:
|
|
679
|
+
"""Find or create the buildings transform group in the scene.
|
|
680
|
+
|
|
681
|
+
Arguments:
|
|
682
|
+
scene_node (ET.Element): The scene element of the XML tree
|
|
683
|
+
|
|
684
|
+
Returns:
|
|
685
|
+
ET.Element: The buildings transform group element
|
|
686
|
+
"""
|
|
687
|
+
# Look for existing buildings group in the scene
|
|
688
|
+
for transform_group in scene_node.iter("TransformGroup"):
|
|
689
|
+
if transform_group.get("name") == "buildings":
|
|
690
|
+
return transform_group
|
|
691
|
+
|
|
692
|
+
# Create new buildings group if not found using the proper element creation method
|
|
693
|
+
buildings_group = self.create_element(
|
|
694
|
+
"TransformGroup",
|
|
695
|
+
{
|
|
696
|
+
"name": "buildings",
|
|
697
|
+
"translation": "0 0 0",
|
|
698
|
+
"nodeId": str(BUILDINGS_STARTING_NODE_ID),
|
|
699
|
+
},
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
scene_node.append(buildings_group)
|
|
703
|
+
return buildings_group
|
|
704
|
+
|
|
705
|
+
def info_sequence(self) -> dict[str, dict[str, str | float | int]]:
|
|
706
|
+
return {}
|