maps4fs 1.8.0__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- 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
|