maps4fs 1.8.0__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/__init__.py +22 -0
- maps4fs/generator/__init__.py +1 -0
- maps4fs/generator/background.py +625 -0
- maps4fs/generator/component.py +553 -0
- maps4fs/generator/config.py +109 -0
- maps4fs/generator/dem.py +297 -0
- maps4fs/generator/dtm/__init__.py +0 -0
- maps4fs/generator/dtm/base/wcs.py +71 -0
- maps4fs/generator/dtm/base/wms.py +70 -0
- maps4fs/generator/dtm/bavaria.py +113 -0
- maps4fs/generator/dtm/dtm.py +637 -0
- maps4fs/generator/dtm/england.py +31 -0
- maps4fs/generator/dtm/hessen.py +31 -0
- maps4fs/generator/dtm/niedersachsen.py +39 -0
- maps4fs/generator/dtm/nrw.py +30 -0
- maps4fs/generator/dtm/srtm.py +127 -0
- maps4fs/generator/dtm/usgs.py +87 -0
- maps4fs/generator/dtm/utils.py +61 -0
- maps4fs/generator/game.py +247 -0
- maps4fs/generator/grle.py +470 -0
- maps4fs/generator/i3d.py +624 -0
- maps4fs/generator/map.py +275 -0
- maps4fs/generator/qgis.py +196 -0
- maps4fs/generator/satellite.py +92 -0
- maps4fs/generator/settings.py +187 -0
- maps4fs/generator/texture.py +893 -0
- maps4fs/logger.py +46 -0
- maps4fs/toolbox/__init__.py +1 -0
- maps4fs/toolbox/background.py +63 -0
- maps4fs/toolbox/custom_osm.py +67 -0
- maps4fs/toolbox/dem.py +112 -0
- maps4fs-1.8.0.dist-info/LICENSE.md +190 -0
- maps4fs-1.8.0.dist-info/METADATA +693 -0
- maps4fs-1.8.0.dist-info/RECORD +36 -0
- maps4fs-1.8.0.dist-info/WHEEL +5 -0
- maps4fs-1.8.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,893 @@
|
|
1
|
+
"""Module with Texture class for generating textures for the map using OSM data."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import json
|
6
|
+
import os
|
7
|
+
import re
|
8
|
+
import shutil
|
9
|
+
import warnings
|
10
|
+
from collections import defaultdict
|
11
|
+
from typing import Any, Callable, Generator, Optional
|
12
|
+
|
13
|
+
import cv2
|
14
|
+
import numpy as np
|
15
|
+
import osmnx as ox # type: ignore
|
16
|
+
import pandas as pd
|
17
|
+
import shapely.geometry # type: ignore
|
18
|
+
from shapely.geometry.base import BaseGeometry # type: ignore
|
19
|
+
from tqdm import tqdm
|
20
|
+
|
21
|
+
from maps4fs.generator.component import Component
|
22
|
+
|
23
|
+
PREVIEW_MAXIMUM_SIZE = 2048
|
24
|
+
|
25
|
+
|
26
|
+
# pylint: disable=R0902, R0904
|
27
|
+
class Texture(Component):
|
28
|
+
"""Class which generates textures for the map using OSM data.
|
29
|
+
|
30
|
+
Attributes:
|
31
|
+
weights_dir (str): Path to the directory with weights.
|
32
|
+
name (str): Name of the texture.
|
33
|
+
tags (dict[str, str | list[str] | bool]): Dictionary of tags to search for.
|
34
|
+
width (int | None): Width of the polygon in meters (only for LineString).
|
35
|
+
color (tuple[int, int, int]): Color of the layer in BGR format.
|
36
|
+
"""
|
37
|
+
|
38
|
+
# pylint: disable=R0903
|
39
|
+
class Layer:
|
40
|
+
"""Class which represents a layer with textures and tags.
|
41
|
+
It's using to obtain data from OSM using tags and make changes into corresponding textures.
|
42
|
+
|
43
|
+
Arguments:
|
44
|
+
name (str): Name of the layer.
|
45
|
+
tags (dict[str, str | list[str]]): Dictionary of tags to search for.
|
46
|
+
width (int | None): Width of the polygon in meters (only for LineString).
|
47
|
+
color (tuple[int, int, int]): Color of the layer in BGR format.
|
48
|
+
exclude_weight (bool): Flag to exclude weight from the texture.
|
49
|
+
priority (int | None): Priority of the layer.
|
50
|
+
info_layer (str | None): Name of the corresnponding info layer.
|
51
|
+
usage (str | None): Usage of the layer.
|
52
|
+
background (bool): Flag to determine if the layer is a background.
|
53
|
+
invisible (bool): Flag to determine if the layer is invisible.
|
54
|
+
|
55
|
+
Attributes:
|
56
|
+
name (str): Name of the layer.
|
57
|
+
tags (dict[str, str | list[str]]): Dictionary of tags to search for.
|
58
|
+
width (int | None): Width of the polygon in meters (only for LineString).
|
59
|
+
"""
|
60
|
+
|
61
|
+
# pylint: disable=R0913
|
62
|
+
def __init__( # pylint: disable=R0917
|
63
|
+
self,
|
64
|
+
name: str,
|
65
|
+
count: int,
|
66
|
+
tags: dict[str, str | list[str] | bool] | None = None,
|
67
|
+
width: int | None = None,
|
68
|
+
color: tuple[int, int, int] | list[int] | None = None,
|
69
|
+
exclude_weight: bool = False,
|
70
|
+
priority: int | None = None,
|
71
|
+
info_layer: str | None = None,
|
72
|
+
usage: str | None = None,
|
73
|
+
background: bool = False,
|
74
|
+
invisible: bool = False,
|
75
|
+
procedural: list[str] | None = None,
|
76
|
+
border: int | None = None,
|
77
|
+
):
|
78
|
+
self.name = name
|
79
|
+
self.count = count
|
80
|
+
self.tags = tags
|
81
|
+
self.width = width
|
82
|
+
self.color = color if color else (255, 255, 255)
|
83
|
+
self.exclude_weight = exclude_weight
|
84
|
+
self.priority = priority
|
85
|
+
self.info_layer = info_layer
|
86
|
+
self.usage = usage
|
87
|
+
self.background = background
|
88
|
+
self.invisible = invisible
|
89
|
+
self.procedural = procedural
|
90
|
+
self.border = border
|
91
|
+
|
92
|
+
def to_json(self) -> dict[str, str | list[str] | bool]: # type: ignore
|
93
|
+
"""Returns dictionary with layer data.
|
94
|
+
|
95
|
+
Returns:
|
96
|
+
dict: Dictionary with layer data."""
|
97
|
+
data = {
|
98
|
+
"name": self.name,
|
99
|
+
"count": self.count,
|
100
|
+
"tags": self.tags,
|
101
|
+
"width": self.width,
|
102
|
+
"color": list(self.color),
|
103
|
+
"exclude_weight": self.exclude_weight,
|
104
|
+
"priority": self.priority,
|
105
|
+
"info_layer": self.info_layer,
|
106
|
+
"usage": self.usage,
|
107
|
+
"background": self.background,
|
108
|
+
"invisible": self.invisible,
|
109
|
+
"procedural": self.procedural,
|
110
|
+
"border": self.border,
|
111
|
+
}
|
112
|
+
|
113
|
+
data = {k: v for k, v in data.items() if v is not None}
|
114
|
+
return data # type: ignore
|
115
|
+
|
116
|
+
@classmethod
|
117
|
+
def from_json(cls, data: dict[str, str | list[str] | bool]) -> Texture.Layer:
|
118
|
+
"""Creates a new instance of the class from dictionary.
|
119
|
+
|
120
|
+
Arguments:
|
121
|
+
data (dict[str, str | list[str] | bool]): Dictionary with layer data.
|
122
|
+
|
123
|
+
Returns:
|
124
|
+
Layer: New instance of the class.
|
125
|
+
"""
|
126
|
+
return cls(**data) # type: ignore
|
127
|
+
|
128
|
+
def path(self, weights_directory: str) -> str:
|
129
|
+
"""Returns path to the first texture of the layer.
|
130
|
+
|
131
|
+
Arguments:
|
132
|
+
weights_directory (str): Path to the directory with weights.
|
133
|
+
|
134
|
+
Returns:
|
135
|
+
str: Path to the texture.
|
136
|
+
"""
|
137
|
+
idx = "01" if self.count > 0 else ""
|
138
|
+
weight_postfix = "_weight" if not self.exclude_weight else ""
|
139
|
+
return os.path.join(weights_directory, f"{self.name}{idx}{weight_postfix}.png")
|
140
|
+
|
141
|
+
def path_preview(self, weights_directory: str) -> str:
|
142
|
+
"""Returns path to the preview of the first texture of the layer.
|
143
|
+
|
144
|
+
Arguments:
|
145
|
+
weights_directory (str): Path to the directory with weights.
|
146
|
+
|
147
|
+
Returns:
|
148
|
+
str: Path to the preview.
|
149
|
+
"""
|
150
|
+
return self.path(weights_directory).replace(".png", "_preview.png")
|
151
|
+
|
152
|
+
def get_preview_or_path(self, weights_directory: str) -> str:
|
153
|
+
"""Returns path to the preview of the first texture of the layer if it exists,
|
154
|
+
otherwise returns path to the texture.
|
155
|
+
|
156
|
+
Arguments:
|
157
|
+
weights_directory (str): Path to the directory with weights.
|
158
|
+
|
159
|
+
Returns:
|
160
|
+
str: Path to the preview or texture.
|
161
|
+
"""
|
162
|
+
preview_path = self.path_preview(weights_directory)
|
163
|
+
return preview_path if os.path.isfile(preview_path) else self.path(weights_directory)
|
164
|
+
|
165
|
+
def paths(self, weights_directory: str) -> list[str]:
|
166
|
+
"""Returns a list of paths to the textures of the layer.
|
167
|
+
NOTE: Works only after the textures are generated, since it just lists the directory.
|
168
|
+
|
169
|
+
Arguments:
|
170
|
+
weights_directory (str): Path to the directory with weights.
|
171
|
+
|
172
|
+
Returns:
|
173
|
+
list[str]: List of paths to the textures.
|
174
|
+
"""
|
175
|
+
weight_files = os.listdir(weights_directory)
|
176
|
+
|
177
|
+
# Inconsistent names are the name of textures that are not following the pattern
|
178
|
+
# of texture_name{idx}_weight.png.
|
179
|
+
inconsistent_names = ["forestRockRoot", "waterPuddle"]
|
180
|
+
|
181
|
+
if self.name in inconsistent_names:
|
182
|
+
return [
|
183
|
+
os.path.join(weights_directory, weight_file)
|
184
|
+
for weight_file in weight_files
|
185
|
+
if weight_file.startswith(self.name)
|
186
|
+
]
|
187
|
+
|
188
|
+
return [
|
189
|
+
os.path.join(weights_directory, weight_file)
|
190
|
+
for weight_file in weight_files
|
191
|
+
if re.match(rf"{self.name}\d{{2}}_weight.png", weight_file)
|
192
|
+
]
|
193
|
+
|
194
|
+
def preprocess(self) -> None:
|
195
|
+
"""Preprocesses the data before the generation."""
|
196
|
+
custom_schema = self.kwargs.get("texture_custom_schema")
|
197
|
+
if custom_schema:
|
198
|
+
layers_schema = custom_schema # type: ignore
|
199
|
+
self.logger.debug("Custom schema loaded with %s layers.", len(layers_schema))
|
200
|
+
else:
|
201
|
+
if not os.path.isfile(self.game.texture_schema):
|
202
|
+
raise FileNotFoundError(
|
203
|
+
f"Texture layers schema not found: {self.game.texture_schema}"
|
204
|
+
)
|
205
|
+
|
206
|
+
try:
|
207
|
+
with open(self.game.texture_schema, "r", encoding="utf-8") as f:
|
208
|
+
layers_schema = json.load(f)
|
209
|
+
except json.JSONDecodeError as e:
|
210
|
+
raise ValueError(f"Error loading texture layers schema: {e}") from e
|
211
|
+
|
212
|
+
try:
|
213
|
+
self.layers = [self.Layer.from_json(layer) for layer in layers_schema] # type: ignore
|
214
|
+
self.logger.debug("Loaded %s layers.", len(self.layers))
|
215
|
+
except Exception as e: # pylint: disable=W0703
|
216
|
+
raise ValueError(f"Error loading texture layers: {e}") from e
|
217
|
+
|
218
|
+
base_layer = self.get_base_layer()
|
219
|
+
if base_layer:
|
220
|
+
self.logger.debug("Base layer found: %s.", base_layer.name)
|
221
|
+
|
222
|
+
self._weights_dir = self.game.weights_dir_path(self.map_directory)
|
223
|
+
self.logger.debug("Weights directory: %s.", self._weights_dir)
|
224
|
+
self.procedural_dir = os.path.join(self._weights_dir, "masks")
|
225
|
+
os.makedirs(self.procedural_dir, exist_ok=True)
|
226
|
+
self.logger.debug("Procedural directory: %s.", self.procedural_dir)
|
227
|
+
|
228
|
+
self.info_save_path = os.path.join(self.map_directory, "generation_info.json")
|
229
|
+
self.logger.debug("Generation info save path: %s.", self.info_save_path)
|
230
|
+
|
231
|
+
self.info_layer_path = os.path.join(self.info_layers_directory, "textures.json")
|
232
|
+
self.logger.debug("Info layer path: %s.", self.info_layer_path)
|
233
|
+
|
234
|
+
def get_base_layer(self) -> Layer | None:
|
235
|
+
"""Returns base layer.
|
236
|
+
|
237
|
+
Returns:
|
238
|
+
Layer | None: Base layer.
|
239
|
+
"""
|
240
|
+
for layer in self.layers:
|
241
|
+
if layer.priority == 0:
|
242
|
+
return layer
|
243
|
+
return None
|
244
|
+
|
245
|
+
def get_background_layers(self) -> list[Layer]:
|
246
|
+
"""Returns list of background layers.
|
247
|
+
|
248
|
+
Returns:
|
249
|
+
list[Layer]: List of background layers.
|
250
|
+
"""
|
251
|
+
return [layer for layer in self.layers if layer.background]
|
252
|
+
|
253
|
+
def get_layer_by_usage(self, usage: str) -> Layer | None:
|
254
|
+
"""Returns layer by usage.
|
255
|
+
|
256
|
+
Arguments:
|
257
|
+
usage (str): Usage of the layer.
|
258
|
+
|
259
|
+
Returns:
|
260
|
+
Layer | None: Layer.
|
261
|
+
"""
|
262
|
+
for layer in self.layers:
|
263
|
+
if layer.usage == usage:
|
264
|
+
return layer
|
265
|
+
return None
|
266
|
+
|
267
|
+
def process(self) -> None:
|
268
|
+
"""Processes the data to generate textures."""
|
269
|
+
self._prepare_weights()
|
270
|
+
self._read_parameters()
|
271
|
+
self.draw()
|
272
|
+
self.rotate_textures()
|
273
|
+
self.add_borders()
|
274
|
+
if self.map.texture_settings.dissolve and self.game.code != "FS22":
|
275
|
+
# FS22 has textures splitted into 4 sublayers, which leads to a very
|
276
|
+
# long processing time when dissolving them.
|
277
|
+
self.dissolve()
|
278
|
+
self.copy_procedural()
|
279
|
+
|
280
|
+
# pylint: disable=no-member
|
281
|
+
def add_borders(self) -> None:
|
282
|
+
"""Iterates over all the layers and picks the one which have the border propety defined.
|
283
|
+
Borders are distance from the edge of the map on each side (top, right, bottom, left).
|
284
|
+
On the layers those pixels will be removed (value set to 0). If the base layer exist in
|
285
|
+
the schema, those pixel values (not 0) will be added as 255 to the base layer."""
|
286
|
+
base_layer = self.get_base_layer()
|
287
|
+
base_layer_image = None
|
288
|
+
if base_layer:
|
289
|
+
base_layer_image = cv2.imread(base_layer.path(self._weights_dir), cv2.IMREAD_UNCHANGED)
|
290
|
+
|
291
|
+
layers_with_borders = [layer for layer in self.layers if layer.border is not None]
|
292
|
+
|
293
|
+
for layer in layers_with_borders:
|
294
|
+
# Read the image.
|
295
|
+
# Read pixels on borders with specified width (border property).
|
296
|
+
# Where the pixel value is 255 - set it to 255 in base layer image.
|
297
|
+
# And set it to 0 in the current layer image.
|
298
|
+
layer_image = cv2.imread(layer.path(self._weights_dir), cv2.IMREAD_UNCHANGED)
|
299
|
+
border = layer.border
|
300
|
+
if border == 0:
|
301
|
+
continue
|
302
|
+
|
303
|
+
top = layer_image[:border, :] # type: ignore
|
304
|
+
right = layer_image[:, -border:] # type: ignore
|
305
|
+
bottom = layer_image[-border:, :] # type: ignore
|
306
|
+
left = layer_image[:, :border] # type: ignore
|
307
|
+
|
308
|
+
if base_layer_image is not None:
|
309
|
+
base_layer_image[:border, :][top != 0] = 255 # type: ignore
|
310
|
+
base_layer_image[:, -border:][right != 0] = 255 # type: ignore
|
311
|
+
base_layer_image[-border:, :][bottom != 0] = 255 # type: ignore
|
312
|
+
base_layer_image[:, :border][left != 0] = 255 # type: ignore
|
313
|
+
|
314
|
+
layer_image[:border, :] = 0 # type: ignore
|
315
|
+
layer_image[:, -border:] = 0 # type: ignore
|
316
|
+
layer_image[-border:, :] = 0 # type: ignore
|
317
|
+
layer_image[:, :border] = 0 # type: ignore
|
318
|
+
|
319
|
+
cv2.imwrite(layer.path(self._weights_dir), layer_image)
|
320
|
+
self.logger.debug("Borders added to layer %s.", layer.name)
|
321
|
+
|
322
|
+
if base_layer_image is not None:
|
323
|
+
cv2.imwrite(base_layer.path(self._weights_dir), base_layer_image) # type: ignore
|
324
|
+
self.logger.debug("Borders added to base layer %s.", base_layer.name) # type: ignore
|
325
|
+
|
326
|
+
def copy_procedural(self) -> None:
|
327
|
+
"""Copies some of the textures to use them as mask for procedural generation.
|
328
|
+
Creates an empty blockmask if it does not exist."""
|
329
|
+
blockmask_path = os.path.join(self.procedural_dir, "BLOCKMASK.png")
|
330
|
+
if not os.path.isfile(blockmask_path):
|
331
|
+
self.logger.debug("BLOCKMASK.png not found, creating an empty file.")
|
332
|
+
img = np.zeros((self.map_size, self.map_size), dtype=np.uint8)
|
333
|
+
cv2.imwrite(blockmask_path, img) # pylint: disable=no-member
|
334
|
+
|
335
|
+
pg_layers_by_type = defaultdict(list)
|
336
|
+
for layer in self.layers:
|
337
|
+
if layer.procedural:
|
338
|
+
# Get path to the original file.
|
339
|
+
texture_path = layer.get_preview_or_path(self._weights_dir)
|
340
|
+
for procedural_layer_name in layer.procedural:
|
341
|
+
pg_layers_by_type[procedural_layer_name].append(texture_path)
|
342
|
+
|
343
|
+
if not pg_layers_by_type:
|
344
|
+
self.logger.debug("No procedural layers found.")
|
345
|
+
return
|
346
|
+
|
347
|
+
for procedural_layer_name, texture_paths in pg_layers_by_type.items():
|
348
|
+
procedural_save_path = os.path.join(self.procedural_dir, f"{procedural_layer_name}.png")
|
349
|
+
if len(texture_paths) > 1:
|
350
|
+
# If there are more than one texture, merge them.
|
351
|
+
merged_texture = np.zeros((self.map_size, self.map_size), dtype=np.uint8)
|
352
|
+
for texture_path in texture_paths:
|
353
|
+
# pylint: disable=E1101
|
354
|
+
texture = cv2.imread(texture_path, cv2.IMREAD_UNCHANGED)
|
355
|
+
merged_texture[texture == 255] = 255
|
356
|
+
cv2.imwrite(procedural_save_path, merged_texture) # pylint: disable=no-member
|
357
|
+
self.logger.debug(
|
358
|
+
"Procedural file %s merged from %s textures.",
|
359
|
+
procedural_save_path,
|
360
|
+
len(texture_paths),
|
361
|
+
)
|
362
|
+
elif len(texture_paths) == 1:
|
363
|
+
# Otherwise, copy the texture.
|
364
|
+
shutil.copyfile(texture_paths[0], procedural_save_path)
|
365
|
+
self.logger.debug(
|
366
|
+
"Procedural file %s copied from %s.", procedural_save_path, texture_paths[0]
|
367
|
+
)
|
368
|
+
|
369
|
+
def rotate_textures(self) -> None:
|
370
|
+
"""Rotates textures of the layers which have tags."""
|
371
|
+
if self.rotation:
|
372
|
+
# Iterate over the layers which have tags and rotate them.
|
373
|
+
for layer in tqdm(self.layers, desc="Rotating textures", unit="layer"):
|
374
|
+
if layer.tags:
|
375
|
+
self.logger.debug("Rotating layer %s.", layer.name)
|
376
|
+
layer_paths = layer.paths(self._weights_dir)
|
377
|
+
layer_paths += [layer.path_preview(self._weights_dir)]
|
378
|
+
for layer_path in layer_paths:
|
379
|
+
if os.path.isfile(layer_path):
|
380
|
+
self.rotate_image(
|
381
|
+
layer_path,
|
382
|
+
self.rotation,
|
383
|
+
output_height=self.map_size,
|
384
|
+
output_width=self.map_size,
|
385
|
+
)
|
386
|
+
else:
|
387
|
+
self.logger.debug(
|
388
|
+
"Skipping rotation of layer %s because it has no tags.", layer.name
|
389
|
+
)
|
390
|
+
|
391
|
+
# pylint: disable=W0201
|
392
|
+
def _read_parameters(self) -> None:
|
393
|
+
"""Reads map parameters from OSM data, such as:
|
394
|
+
- minimum and maximum coordinates
|
395
|
+
- map dimensions in meters
|
396
|
+
- map coefficients (meters per pixel)
|
397
|
+
"""
|
398
|
+
bbox = ox.utils_geo.bbox_from_point(self.coordinates, dist=self.map_rotated_size / 2)
|
399
|
+
self.minimum_x, self.minimum_y, self.maximum_x, self.maximum_y = bbox
|
400
|
+
|
401
|
+
def info_sequence(self) -> dict[str, Any]:
|
402
|
+
"""Returns the JSON representation of the generation info for textures."""
|
403
|
+
useful_attributes = [
|
404
|
+
"coordinates",
|
405
|
+
"bbox",
|
406
|
+
"map_size",
|
407
|
+
"rotation",
|
408
|
+
"minimum_x",
|
409
|
+
"minimum_y",
|
410
|
+
"maximum_x",
|
411
|
+
"maximum_y",
|
412
|
+
]
|
413
|
+
return {attr: getattr(self, attr, None) for attr in useful_attributes}
|
414
|
+
|
415
|
+
def _prepare_weights(self):
|
416
|
+
self.logger.debug("Starting preparing weights from %s layers.", len(self.layers))
|
417
|
+
|
418
|
+
for layer in tqdm(self.layers, desc="Preparing weights", unit="layer"):
|
419
|
+
self._generate_weights(layer)
|
420
|
+
self.logger.debug("Prepared weights for %s layers.", len(self.layers))
|
421
|
+
|
422
|
+
def _generate_weights(self, layer: Layer) -> None:
|
423
|
+
"""Generates weight files for textures. Each file is a numpy array of zeros and
|
424
|
+
dtype uint8 (0-255).
|
425
|
+
|
426
|
+
Arguments:
|
427
|
+
layer (Layer): Layer with textures and tags.
|
428
|
+
"""
|
429
|
+
if layer.tags is None:
|
430
|
+
size = (self.map_size, self.map_size)
|
431
|
+
else:
|
432
|
+
size = (self.map_rotated_size, self.map_rotated_size)
|
433
|
+
postfix = "_weight.png" if not layer.exclude_weight else ".png"
|
434
|
+
if layer.count == 0:
|
435
|
+
filepaths = [os.path.join(self._weights_dir, layer.name + postfix)]
|
436
|
+
else:
|
437
|
+
filepaths = [
|
438
|
+
os.path.join(self._weights_dir, layer.name + str(i).zfill(2) + postfix)
|
439
|
+
for i in range(1, layer.count + 1)
|
440
|
+
]
|
441
|
+
|
442
|
+
for filepath in filepaths:
|
443
|
+
img = np.zeros(size, dtype=np.uint8)
|
444
|
+
cv2.imwrite(filepath, img) # pylint: disable=no-member
|
445
|
+
|
446
|
+
@property
|
447
|
+
def layers(self) -> list[Layer]:
|
448
|
+
"""Returns list of layers with textures and tags from textures.json.
|
449
|
+
|
450
|
+
Returns:
|
451
|
+
list[Layer]: List of layers.
|
452
|
+
"""
|
453
|
+
return self._layers
|
454
|
+
|
455
|
+
@layers.setter
|
456
|
+
def layers(self, layers: list[Layer]) -> None:
|
457
|
+
"""Sets list of layers with textures and tags.
|
458
|
+
|
459
|
+
Arguments:
|
460
|
+
layers (list[Layer]): List of layers.
|
461
|
+
"""
|
462
|
+
self._layers = layers
|
463
|
+
|
464
|
+
def layers_by_priority(self) -> list[Layer]:
|
465
|
+
"""Returns list of layers sorted by priority: None priority layers are first,
|
466
|
+
then layers are sorted by priority (descending).
|
467
|
+
|
468
|
+
Returns:
|
469
|
+
list[Layer]: List of layers sorted by priority.
|
470
|
+
"""
|
471
|
+
return sorted(
|
472
|
+
self.layers,
|
473
|
+
key=lambda _layer: (
|
474
|
+
_layer.priority is not None,
|
475
|
+
-_layer.priority if _layer.priority is not None else float("inf"),
|
476
|
+
),
|
477
|
+
)
|
478
|
+
|
479
|
+
# pylint: disable=no-member, R0912, R0915
|
480
|
+
def draw(self) -> None:
|
481
|
+
"""Iterates over layers and fills them with polygons from OSM data."""
|
482
|
+
layers = self.layers_by_priority()
|
483
|
+
|
484
|
+
self.logger.debug(
|
485
|
+
"Sorted layers by priority: %s.", [(layer.name, layer.priority) for layer in layers]
|
486
|
+
)
|
487
|
+
|
488
|
+
cumulative_image = None
|
489
|
+
|
490
|
+
# Dictionary to store info layer data.
|
491
|
+
# Key is a layer.info_layer, value is a list of polygon points as tuples (x, y).
|
492
|
+
info_layer_data = defaultdict(list)
|
493
|
+
|
494
|
+
for layer in tqdm(layers, desc="Drawing textures", unit="layer"):
|
495
|
+
if self.map.texture_settings.skip_drains and layer.usage == "drain":
|
496
|
+
self.logger.debug("Skipping layer %s because of the usage.", layer.name)
|
497
|
+
continue
|
498
|
+
if not layer.tags:
|
499
|
+
self.logger.debug("Layer %s has no tags, there's nothing to draw.", layer.name)
|
500
|
+
continue
|
501
|
+
if layer.priority == 0:
|
502
|
+
self.logger.debug(
|
503
|
+
"Found base layer %s. Postponing that to be the last layer drawn.", layer.name
|
504
|
+
)
|
505
|
+
continue
|
506
|
+
layer_path = layer.path(self._weights_dir)
|
507
|
+
self.logger.debug("Drawing layer %s.", layer_path)
|
508
|
+
layer_image = cv2.imread(layer_path, cv2.IMREAD_UNCHANGED)
|
509
|
+
|
510
|
+
if cumulative_image is None:
|
511
|
+
self.logger.debug("First layer, creating new cumulative image.")
|
512
|
+
cumulative_image = layer_image
|
513
|
+
|
514
|
+
mask = cv2.bitwise_not(cumulative_image)
|
515
|
+
|
516
|
+
for polygon in self.objects_generator( # type: ignore
|
517
|
+
layer.tags, layer.width, layer.info_layer
|
518
|
+
):
|
519
|
+
if not len(polygon) > 2:
|
520
|
+
self.logger.debug("Skipping polygon with less than 3 points.")
|
521
|
+
continue
|
522
|
+
if layer.info_layer:
|
523
|
+
info_layer_data[layer.info_layer].append(
|
524
|
+
self.np_to_polygon_points(polygon) # type: ignore
|
525
|
+
)
|
526
|
+
if not layer.invisible:
|
527
|
+
try:
|
528
|
+
cv2.fillPoly(layer_image, [polygon], color=255) # type: ignore
|
529
|
+
except Exception as e: # pylint: disable=W0718
|
530
|
+
self.logger.warning("Error drawing polygon: %s.", repr(e))
|
531
|
+
continue
|
532
|
+
|
533
|
+
if layer.info_layer == "roads":
|
534
|
+
for linestring in self.objects_generator(
|
535
|
+
layer.tags, layer.width, layer.info_layer, yield_linestrings=True
|
536
|
+
):
|
537
|
+
info_layer_data[f"{layer.info_layer}_polylines"].append(
|
538
|
+
linestring # type: ignore
|
539
|
+
)
|
540
|
+
|
541
|
+
output_image = cv2.bitwise_and(layer_image, mask)
|
542
|
+
|
543
|
+
cumulative_image = cv2.bitwise_or(cumulative_image, output_image)
|
544
|
+
|
545
|
+
cv2.imwrite(layer_path, output_image)
|
546
|
+
self.logger.debug("Texture %s saved.", layer_path)
|
547
|
+
|
548
|
+
# Save info layer data.
|
549
|
+
if os.path.isfile(self.info_layer_path):
|
550
|
+
self.logger.debug(
|
551
|
+
"File %s already exists, will update to avoid overwriting.", self.info_layer_path
|
552
|
+
)
|
553
|
+
with open(self.info_layer_path, "r", encoding="utf-8") as f:
|
554
|
+
info_layer_data.update(json.load(f))
|
555
|
+
|
556
|
+
with open(self.info_layer_path, "w", encoding="utf-8") as f:
|
557
|
+
json.dump(info_layer_data, f, ensure_ascii=False, indent=4)
|
558
|
+
self.logger.debug("Info layer data saved to %s.", self.info_layer_path)
|
559
|
+
|
560
|
+
if cumulative_image is not None:
|
561
|
+
self.draw_base_layer(cumulative_image)
|
562
|
+
|
563
|
+
def dissolve(self) -> None:
|
564
|
+
"""Dissolves textures of the layers with tags into sublayers for them to look more
|
565
|
+
natural in the game.
|
566
|
+
Iterates over all layers with tags and reads the first texture, checks if the file
|
567
|
+
contains any non-zero values (255), splits those non-values between different weight
|
568
|
+
files of the corresponding layer and saves the changes to the files.
|
569
|
+
"""
|
570
|
+
for layer in tqdm(self.layers, desc="Dissolving textures", unit="layer"):
|
571
|
+
if not layer.tags:
|
572
|
+
self.logger.debug("Layer %s has no tags, there's nothing to dissolve.", layer.name)
|
573
|
+
continue
|
574
|
+
layer_path = layer.path(self._weights_dir)
|
575
|
+
layer_paths = layer.paths(self._weights_dir)
|
576
|
+
|
577
|
+
if len(layer_paths) < 2:
|
578
|
+
self.logger.debug("Layer %s has only one texture, skipping.", layer.name)
|
579
|
+
continue
|
580
|
+
|
581
|
+
self.logger.debug("Dissolving layer from %s to %s.", layer_path, layer_paths)
|
582
|
+
|
583
|
+
# Check if the image contains any non-zero values, otherwise continue.
|
584
|
+
layer_image = cv2.imread(layer_path, cv2.IMREAD_UNCHANGED)
|
585
|
+
|
586
|
+
if not np.any(layer_image):
|
587
|
+
self.logger.debug(
|
588
|
+
"Layer %s does not contain any non-zero values, skipping.", layer.name
|
589
|
+
)
|
590
|
+
continue
|
591
|
+
|
592
|
+
# Save the original image to use it for preview later, without combining the sublayers.
|
593
|
+
cv2.imwrite(layer.path_preview(self._weights_dir), layer_image.copy())
|
594
|
+
|
595
|
+
# Get the coordinates of non-zero values.
|
596
|
+
non_zero_coords = np.column_stack(np.where(layer_image > 0))
|
597
|
+
|
598
|
+
# Prepare sublayers.
|
599
|
+
sublayers = [np.zeros_like(layer_image) for _ in range(layer.count)]
|
600
|
+
|
601
|
+
# Randomly assign non-zero values to sublayers.
|
602
|
+
for coord in non_zero_coords:
|
603
|
+
sublayers[np.random.randint(0, layer.count)][coord[0], coord[1]] = 255
|
604
|
+
|
605
|
+
# Save the sublayers.
|
606
|
+
for sublayer, sublayer_path in zip(sublayers, layer_paths):
|
607
|
+
cv2.imwrite(sublayer_path, sublayer)
|
608
|
+
self.logger.debug("Sublayer %s saved.", sublayer_path)
|
609
|
+
|
610
|
+
self.logger.debug("Dissolved layer %s.", layer.name)
|
611
|
+
|
612
|
+
def draw_base_layer(self, cumulative_image: np.ndarray) -> None:
|
613
|
+
"""Draws base layer and saves it into the png file.
|
614
|
+
Base layer is the last layer to be drawn, it fills the remaining area of the map.
|
615
|
+
|
616
|
+
Arguments:
|
617
|
+
cumulative_image (np.ndarray): Cumulative image with all layers.
|
618
|
+
"""
|
619
|
+
base_layer = self.get_base_layer()
|
620
|
+
if base_layer is not None:
|
621
|
+
layer_path = base_layer.path(self._weights_dir)
|
622
|
+
self.logger.debug("Drawing base layer %s.", layer_path)
|
623
|
+
img = cv2.bitwise_not(cumulative_image)
|
624
|
+
cv2.imwrite(layer_path, img)
|
625
|
+
self.logger.debug("Base texture %s saved.", layer_path)
|
626
|
+
|
627
|
+
def latlon_to_pixel(self, lat: float, lon: float) -> tuple[int, int]:
|
628
|
+
"""Converts latitude and longitude to pixel coordinates.
|
629
|
+
|
630
|
+
Arguments:
|
631
|
+
lat (float): Latitude.
|
632
|
+
lon (float): Longitude.
|
633
|
+
|
634
|
+
Returns:
|
635
|
+
tuple[int, int]: Pixel coordinates.
|
636
|
+
"""
|
637
|
+
x = int((lon - self.minimum_x) / (self.maximum_x - self.minimum_x) * self.map_rotated_size)
|
638
|
+
y = int((lat - self.maximum_y) / (self.minimum_y - self.maximum_y) * self.map_rotated_size)
|
639
|
+
return x, y
|
640
|
+
|
641
|
+
def np_to_polygon_points(self, np_array: np.ndarray) -> list[tuple[int, int]]:
|
642
|
+
"""Converts numpy array of polygon points to list of tuples.
|
643
|
+
|
644
|
+
Arguments:
|
645
|
+
np_array (np.ndarray): Numpy array of polygon points.
|
646
|
+
|
647
|
+
Returns:
|
648
|
+
list[tuple[int, int]]: List of tuples.
|
649
|
+
"""
|
650
|
+
return [(int(x), int(y)) for x, y in np_array.reshape(-1, 2)]
|
651
|
+
|
652
|
+
# pylint: disable=W0613
|
653
|
+
def _to_np(self, geometry: shapely.geometry.polygon.Polygon, *args) -> np.ndarray:
|
654
|
+
"""Converts Polygon geometry to numpy array of polygon points.
|
655
|
+
|
656
|
+
Arguments:
|
657
|
+
geometry (shapely.geometry.polygon.Polygon): Polygon geometry.
|
658
|
+
*Arguments: Additional arguments:
|
659
|
+
- width (int | None): Width of the polygon in meters.
|
660
|
+
|
661
|
+
Returns:
|
662
|
+
np.ndarray: Numpy array of polygon points.
|
663
|
+
"""
|
664
|
+
coords = list(geometry.exterior.coords)
|
665
|
+
pts = np.array(
|
666
|
+
[self.latlon_to_pixel(coord[1], coord[0]) for coord in coords],
|
667
|
+
np.int32,
|
668
|
+
)
|
669
|
+
pts = pts.reshape((-1, 1, 2))
|
670
|
+
return pts
|
671
|
+
|
672
|
+
def _to_polygon(
|
673
|
+
self, obj: pd.core.series.Series, width: int | None
|
674
|
+
) -> shapely.geometry.polygon.Polygon:
|
675
|
+
"""Converts OSM object to numpy array of polygon points.
|
676
|
+
|
677
|
+
Arguments:
|
678
|
+
obj (pd.core.series.Series): OSM object.
|
679
|
+
width (int | None): Width of the polygon in meters.
|
680
|
+
|
681
|
+
Returns:
|
682
|
+
shapely.geometry.polygon.Polygon: Polygon geometry.
|
683
|
+
"""
|
684
|
+
geometry = obj["geometry"]
|
685
|
+
geometry_type = geometry.geom_type
|
686
|
+
converter = self._converters(geometry_type)
|
687
|
+
if not converter:
|
688
|
+
self.logger.debug("Geometry type %s not supported.", geometry_type)
|
689
|
+
return None
|
690
|
+
return converter(geometry, width)
|
691
|
+
|
692
|
+
def _sequence(
|
693
|
+
self,
|
694
|
+
geometry: shapely.geometry.linestring.LineString | shapely.geometry.point.Point,
|
695
|
+
width: int | None,
|
696
|
+
) -> shapely.geometry.polygon.Polygon:
|
697
|
+
"""Converts LineString or Point geometry to numpy array of polygon points.
|
698
|
+
|
699
|
+
Arguments:
|
700
|
+
geometry (shapely.geometry.linestring.LineString | shapely.geometry.point.Point):
|
701
|
+
LineString or Point geometry.
|
702
|
+
width (int | None): Width of the polygon in meters.
|
703
|
+
|
704
|
+
Returns:
|
705
|
+
shapely.geometry.polygon.Polygon: Polygon geometry.
|
706
|
+
"""
|
707
|
+
polygon = geometry.buffer(self.meters_to_degrees(width) if width else 0)
|
708
|
+
return polygon
|
709
|
+
|
710
|
+
def meters_to_degrees(self, meters: int) -> float:
|
711
|
+
"""Converts meters to degrees.
|
712
|
+
|
713
|
+
Arguments:
|
714
|
+
meters (int): Meters.
|
715
|
+
|
716
|
+
Returns:
|
717
|
+
float: Degrees.
|
718
|
+
"""
|
719
|
+
return meters / 111320
|
720
|
+
|
721
|
+
def _skip(
|
722
|
+
self, geometry: shapely.geometry.polygon.Polygon, *args, **kwargs
|
723
|
+
) -> shapely.geometry.polygon.Polygon:
|
724
|
+
"""Returns the same geometry.
|
725
|
+
|
726
|
+
Arguments:
|
727
|
+
geometry (shapely.geometry.polygon.Polygon): Polygon geometry.
|
728
|
+
|
729
|
+
Returns:
|
730
|
+
shapely.geometry.polygon.Polygon: Polygon geometry.
|
731
|
+
"""
|
732
|
+
return geometry
|
733
|
+
|
734
|
+
def _converters(
|
735
|
+
self, geom_type: str
|
736
|
+
) -> Optional[Callable[[BaseGeometry, Optional[int]], np.ndarray]]:
|
737
|
+
"""Returns a converter function for a given geometry type.
|
738
|
+
|
739
|
+
Arguments:
|
740
|
+
geom_type (str): Geometry type.
|
741
|
+
|
742
|
+
Returns:
|
743
|
+
Callable[[shapely.geometry, int | None], np.ndarray]: Converter function.
|
744
|
+
"""
|
745
|
+
converters = {"Polygon": self._skip, "LineString": self._sequence, "Point": self._sequence}
|
746
|
+
return converters.get(geom_type) # type: ignore
|
747
|
+
|
748
|
+
def objects_generator(
|
749
|
+
self,
|
750
|
+
tags: dict[str, str | list[str] | bool],
|
751
|
+
width: int | None,
|
752
|
+
info_layer: str | None = None,
|
753
|
+
yield_linestrings: bool = False,
|
754
|
+
) -> Generator[np.ndarray, None, None] | Generator[list[tuple[int, int]], None, None]:
|
755
|
+
"""Generator which yields numpy arrays of polygons from OSM data.
|
756
|
+
|
757
|
+
Arguments:
|
758
|
+
tags (dict[str, str | list[str]]): Dictionary of tags to search for.
|
759
|
+
width (int | None): Width of the polygon in meters (only for LineString).
|
760
|
+
info_layer (str | None): Name of the corresponding info layer.
|
761
|
+
yield_linestrings (bool): Flag to determine if the LineStrings should be yielded.
|
762
|
+
|
763
|
+
Yields:
|
764
|
+
Generator[np.ndarray, None, None] | Generator[list[tuple[int, int]], None, None]:
|
765
|
+
Numpy array of polygon points or list of point coordinates.
|
766
|
+
"""
|
767
|
+
is_fieds = info_layer == "fields"
|
768
|
+
try:
|
769
|
+
if self.map.custom_osm is not None:
|
770
|
+
with warnings.catch_warnings():
|
771
|
+
warnings.simplefilter("ignore", FutureWarning)
|
772
|
+
objects = ox.features_from_xml(self.map.custom_osm, tags=tags)
|
773
|
+
else:
|
774
|
+
objects = ox.features_from_bbox(bbox=self.new_bbox, tags=tags)
|
775
|
+
except Exception as e: # pylint: disable=W0718
|
776
|
+
self.logger.debug("Error fetching objects for tags: %s. Error: %s.", tags, e)
|
777
|
+
return
|
778
|
+
self.logger.debug("Fetched %s elements for tags: %s.", len(objects), tags)
|
779
|
+
|
780
|
+
method = self.linestrings_generator if yield_linestrings else self.polygons_generator
|
781
|
+
|
782
|
+
yield from method(objects, width, is_fieds)
|
783
|
+
|
784
|
+
def linestrings_generator(
|
785
|
+
self, objects: pd.core.frame.DataFrame, *args, **kwargs
|
786
|
+
) -> Generator[list[tuple[int, int]], None, None]:
|
787
|
+
"""Generator which yields lists of point coordinates which represent LineStrings from OSM.
|
788
|
+
|
789
|
+
Arguments:
|
790
|
+
objects (pd.core.frame.DataFrame): Dataframe with OSM objects.
|
791
|
+
|
792
|
+
Yields:
|
793
|
+
Generator[list[tuple[int, int]], None, None]: List of point coordinates.
|
794
|
+
"""
|
795
|
+
for _, obj in objects.iterrows():
|
796
|
+
geometry = obj["geometry"]
|
797
|
+
if isinstance(geometry, shapely.geometry.linestring.LineString):
|
798
|
+
points = [self.latlon_to_pixel(x, y) for y, x in geometry.coords]
|
799
|
+
yield points
|
800
|
+
|
801
|
+
def polygons_generator(
|
802
|
+
self, objects: pd.core.frame.DataFrame, width: int | None, is_fieds: bool
|
803
|
+
) -> Generator[np.ndarray, None, None]:
|
804
|
+
"""Generator which yields numpy arrays of polygons from OSM data.
|
805
|
+
|
806
|
+
Arguments:
|
807
|
+
objects (pd.core.frame.DataFrame): Dataframe with OSM objects.
|
808
|
+
width (int | None): Width of the polygon in meters (only for LineString).
|
809
|
+
is_fieds (bool): Flag to determine if the fields should be padded.
|
810
|
+
|
811
|
+
Yields:
|
812
|
+
Generator[np.ndarray, None, None]: Numpy array of polygon points.
|
813
|
+
"""
|
814
|
+
for _, obj in objects.iterrows():
|
815
|
+
try:
|
816
|
+
polygon = self._to_polygon(obj, width)
|
817
|
+
except Exception as e: # pylint: disable=W0703
|
818
|
+
self.logger.warning("Error converting object to polygon: %s.", e)
|
819
|
+
continue
|
820
|
+
if polygon is None:
|
821
|
+
continue
|
822
|
+
|
823
|
+
if is_fieds and self.map.texture_settings.fields_padding > 0:
|
824
|
+
padded_polygon = polygon.buffer(
|
825
|
+
-self.meters_to_degrees(self.map.texture_settings.fields_padding)
|
826
|
+
)
|
827
|
+
|
828
|
+
if not isinstance(padded_polygon, shapely.geometry.polygon.Polygon):
|
829
|
+
self.logger.warning("The padding value is too high, field will not padded.")
|
830
|
+
elif not list(padded_polygon.exterior.coords):
|
831
|
+
self.logger.warning("The padding value is too high, field will not padded.")
|
832
|
+
else:
|
833
|
+
polygon = padded_polygon
|
834
|
+
|
835
|
+
polygon_np = self._to_np(polygon)
|
836
|
+
yield polygon_np
|
837
|
+
|
838
|
+
def previews(self) -> list[str]:
|
839
|
+
"""Invokes methods to generate previews. Returns list of paths to previews.
|
840
|
+
|
841
|
+
Returns:
|
842
|
+
list[str]: List of paths to previews.
|
843
|
+
"""
|
844
|
+
preview_paths = []
|
845
|
+
preview_paths.append(self._osm_preview())
|
846
|
+
return preview_paths
|
847
|
+
|
848
|
+
# pylint: disable=no-member
|
849
|
+
def _osm_preview(self) -> str:
|
850
|
+
"""Merges layers into one image and saves it into the png file.
|
851
|
+
|
852
|
+
Returns:
|
853
|
+
str: Path to the preview.
|
854
|
+
"""
|
855
|
+
scaling_factor = PREVIEW_MAXIMUM_SIZE / self.map_size
|
856
|
+
|
857
|
+
preview_size = (
|
858
|
+
int(self.map_size * scaling_factor),
|
859
|
+
int(self.map_size * scaling_factor),
|
860
|
+
)
|
861
|
+
self.logger.debug(
|
862
|
+
"Scaling factor: %s. Preview size: %s.",
|
863
|
+
scaling_factor,
|
864
|
+
preview_size,
|
865
|
+
)
|
866
|
+
|
867
|
+
active_layers = [layer for layer in self.layers if layer.tags is not None]
|
868
|
+
self.logger.debug("Following layers have tag textures: %s.", len(active_layers))
|
869
|
+
|
870
|
+
images = [
|
871
|
+
cv2.resize(
|
872
|
+
cv2.imread(layer.get_preview_or_path(self._weights_dir), cv2.IMREAD_UNCHANGED),
|
873
|
+
preview_size,
|
874
|
+
)
|
875
|
+
for layer in active_layers
|
876
|
+
]
|
877
|
+
colors = [layer.color for layer in active_layers]
|
878
|
+
color_images = []
|
879
|
+
for img, color in zip(images, colors):
|
880
|
+
color_img = np.zeros((img.shape[0], img.shape[1], 3), dtype=np.uint8)
|
881
|
+
color_img[img > 0] = color
|
882
|
+
color_images.append(color_img)
|
883
|
+
merged = np.sum(color_images, axis=0, dtype=np.uint8)
|
884
|
+
self.logger.debug(
|
885
|
+
"Merged layers into one image. Shape: %s, dtype: %s.",
|
886
|
+
merged.shape,
|
887
|
+
merged.dtype,
|
888
|
+
)
|
889
|
+
preview_path = os.path.join(self.previews_directory, "textures_osm.png")
|
890
|
+
|
891
|
+
cv2.imwrite(preview_path, merged) # type: ignore
|
892
|
+
self.logger.debug("Preview saved to %s.", preview_path)
|
893
|
+
return preview_path
|