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.
@@ -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