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