maps4fs 0.7.8__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,115 @@
1
+ """This module contains Map class, which is used to generate map using all components."""
2
+
3
+ import os
4
+ import shutil
5
+ from typing import Any
6
+
7
+ from tqdm import tqdm
8
+
9
+ from maps4fs.generator.component import Component
10
+ from maps4fs.generator.game import Game
11
+ from maps4fs.logger import Logger
12
+
13
+
14
+ # pylint: disable=R0913, R0902
15
+ class Map:
16
+ """Class used to generate map using all components.
17
+
18
+ Args:
19
+ game (Type[Game]): Game for which the map is generated.
20
+ coordinates (tuple[float, float]): Coordinates of the center of the map.
21
+ height (int): Height of the map in pixels.
22
+ width (int): Width of the map in pixels.
23
+ map_directory (str): Path to the directory where map files will be stored.
24
+ logger (Any): Logger instance
25
+ """
26
+
27
+ def __init__( # pylint: disable=R0917
28
+ self,
29
+ game: Game,
30
+ coordinates: tuple[float, float],
31
+ height: int,
32
+ width: int,
33
+ map_directory: str,
34
+ logger: Any = None,
35
+ **kwargs,
36
+ ):
37
+ self.game = game
38
+ self.components: list[Component] = []
39
+ self.coordinates = coordinates
40
+ self.height = height
41
+ self.width = width
42
+ self.map_directory = map_directory
43
+
44
+ if not logger:
45
+ logger = Logger(__name__, to_stdout=True, to_file=False)
46
+ self.logger = logger
47
+ self.logger.debug("Game was set to %s", game.code)
48
+
49
+ self.kwargs = kwargs
50
+
51
+ os.makedirs(self.map_directory, exist_ok=True)
52
+ self.logger.debug("Map directory created: %s", self.map_directory)
53
+
54
+ try:
55
+ shutil.unpack_archive(game.template_path, self.map_directory)
56
+ self.logger.info("Map template unpacked to %s", self.map_directory)
57
+ except Exception as e:
58
+ raise RuntimeError(f"Can not unpack map template due to error: {e}") from e
59
+
60
+ def generate(self) -> None:
61
+ """Launch map generation using all components."""
62
+ with tqdm(total=len(self.game.components), desc="Generating map...") as pbar:
63
+ for game_component in self.game.components:
64
+ component = game_component(
65
+ self.game,
66
+ self.coordinates,
67
+ self.height,
68
+ self.width,
69
+ self.map_directory,
70
+ self.logger,
71
+ **self.kwargs,
72
+ )
73
+ try:
74
+ component.process()
75
+ except Exception as e: # pylint: disable=W0718
76
+ self.logger.error(
77
+ "Error processing component %s: %s",
78
+ component.__class__.__name__,
79
+ e,
80
+ )
81
+ raise e
82
+ self.components.append(component)
83
+
84
+ pbar.update(1)
85
+
86
+ def previews(self) -> list[str]:
87
+ """Get list of preview images.
88
+
89
+ Returns:
90
+ list[str]: List of preview images.
91
+ """
92
+ previews = []
93
+ for component in self.components:
94
+ try:
95
+ previews.extend(component.previews())
96
+ except Exception as e: # pylint: disable=W0718
97
+ self.logger.error(
98
+ "Error getting previews for component %s: %s",
99
+ component.__class__.__name__,
100
+ e,
101
+ )
102
+ return previews
103
+
104
+ def pack(self, archive_name: str) -> str:
105
+ """Pack map directory to zip archive.
106
+
107
+ Args:
108
+ archive_name (str): Name of the archive.
109
+
110
+ Returns:
111
+ str: Path to the archive.
112
+ """
113
+ archive_path = shutil.make_archive(archive_name, "zip", self.map_directory)
114
+ self.logger.info("Map packed to %s.zip", archive_name)
115
+ return archive_path
@@ -0,0 +1,434 @@
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 warnings
8
+ from typing import Callable, Generator, Optional
9
+
10
+ import cv2
11
+ import numpy as np
12
+ import osmnx as ox # type: ignore
13
+ import pandas as pd
14
+ import shapely.geometry # type: ignore
15
+ from shapely.geometry.base import BaseGeometry # type: ignore
16
+
17
+ from maps4fs.generator.component import Component
18
+
19
+ PREVIEW_MAXIMUM_SIZE = 2048
20
+
21
+
22
+ # pylint: disable=R0902
23
+ class Texture(Component):
24
+ """Class which generates textures for the map using OSM data.
25
+
26
+ Attributes:
27
+ weights_dir (str): Path to the directory with weights.
28
+ name (str): Name of the texture.
29
+ tags (dict[str, str | list[str] | bool]): Dictionary of tags to search for.
30
+ width (int | None): Width of the polygon in meters (only for LineString).
31
+ color (tuple[int, int, int]): Color of the layer in BGR format.
32
+ """
33
+
34
+ # pylint: disable=R0903
35
+ class Layer:
36
+ """Class which represents a layer with textures and tags.
37
+ It's using to obtain data from OSM using tags and make changes into corresponding textures.
38
+
39
+ Args:
40
+ name (str): Name of the layer.
41
+ tags (dict[str, str | list[str]]): Dictionary of tags to search for.
42
+ width (int | None): Width of the polygon in meters (only for LineString).
43
+ color (tuple[int, int, int]): Color of the layer in BGR format.
44
+
45
+ Attributes:
46
+ name (str): Name of the layer.
47
+ tags (dict[str, str | list[str]]): Dictionary of tags to search for.
48
+ width (int | None): Width of the polygon in meters (only for LineString).
49
+ """
50
+
51
+ # pylint: disable=R0913
52
+ def __init__( # pylint: disable=R0917
53
+ self,
54
+ name: str,
55
+ count: int,
56
+ tags: dict[str, str | list[str] | bool] | None = None,
57
+ width: int | None = None,
58
+ color: tuple[int, int, int] | list[int] | None = None,
59
+ exclude_weight: bool = False,
60
+ ):
61
+ self.name = name
62
+ self.count = count
63
+ self.tags = tags
64
+ self.width = width
65
+ self.color = color if color else (255, 255, 255)
66
+ self.exclude_weight = exclude_weight
67
+
68
+ def to_json(self) -> dict[str, str | list[str] | bool]: # type: ignore
69
+ """Returns dictionary with layer data.
70
+
71
+ Returns:
72
+ dict: Dictionary with layer data."""
73
+ data = {
74
+ "name": self.name,
75
+ "count": self.count,
76
+ "tags": self.tags,
77
+ "width": self.width,
78
+ "color": list(self.color),
79
+ "exclude_weight": self.exclude_weight,
80
+ }
81
+
82
+ data = {k: v for k, v in data.items() if v is not None}
83
+ return data # type: ignore
84
+
85
+ @classmethod
86
+ def from_json(cls, data: dict[str, str | list[str] | bool]) -> Texture.Layer:
87
+ """Creates a new instance of the class from dictionary.
88
+
89
+ Args:
90
+ data (dict[str, str | list[str] | bool]): Dictionary with layer data.
91
+
92
+ Returns:
93
+ Layer: New instance of the class.
94
+ """
95
+ return cls(**data) # type: ignore
96
+
97
+ def path(self, weights_directory: str) -> str:
98
+ """Returns path to the first texture of the layer.
99
+
100
+ Arguments:
101
+ weights_directory (str): Path to the directory with weights.
102
+
103
+ Returns:
104
+ str: Path to the texture.
105
+ """
106
+ idx = "01" if self.count > 0 else ""
107
+ weight_postfix = "_weight" if not self.exclude_weight else ""
108
+ return os.path.join(weights_directory, f"{self.name}{idx}{weight_postfix}.png")
109
+
110
+ def preprocess(self) -> None:
111
+ if not os.path.isfile(self.game.texture_schema):
112
+ raise FileNotFoundError(f"Texture layers schema not found: {self.game.texture_schema}")
113
+
114
+ try:
115
+ with open(self.game.texture_schema, "r", encoding="utf-8") as f:
116
+ layers_schema = json.load(f)
117
+ except json.JSONDecodeError as e:
118
+ raise ValueError(f"Error loading texture layers schema: {e}") from e
119
+
120
+ self.layers = [self.Layer.from_json(layer) for layer in layers_schema]
121
+ self.logger.info("Loaded %s layers.", len(self.layers))
122
+
123
+ self._weights_dir = self.game.weights_dir_path(self.map_directory)
124
+ self.logger.debug("Weights directory: %s.", self._weights_dir)
125
+ self.info_save_path = os.path.join(self.map_directory, "generation_info.json")
126
+ self.logger.debug("Generation info save path: %s.", self.info_save_path)
127
+
128
+ def process(self):
129
+ self._prepare_weights()
130
+ self._read_parameters()
131
+ self.draw()
132
+ self.info_sequence()
133
+
134
+ # pylint: disable=W0201
135
+ def _read_parameters(self) -> None:
136
+ """Reads map parameters from OSM data, such as:
137
+ - minimum and maximum coordinates in UTM format
138
+ - map dimensions in meters
139
+ - map coefficients (meters per pixel)
140
+ """
141
+ north, south, east, west = self.get_bbox(project_utm=True)
142
+
143
+ # Parameters of the map in UTM format (meters).
144
+ self.minimum_x = min(west, east)
145
+ self.minimum_y = min(south, north)
146
+ self.maximum_x = max(west, east)
147
+ self.maximum_y = max(south, north)
148
+ self.logger.debug("Map minimum coordinates (XxY): %s x %s.", self.minimum_x, self.minimum_y)
149
+ self.logger.debug("Map maximum coordinates (XxY): %s x %s.", self.maximum_x, self.maximum_y)
150
+
151
+ self.height = abs(north - south)
152
+ self.width = abs(east - west)
153
+ self.logger.info("Map dimensions (HxW): %s x %s.", self.height, self.width)
154
+
155
+ self.height_coef = self.height / self.map_height
156
+ self.width_coef = self.width / self.map_width
157
+ self.logger.debug("Map coefficients (HxW): %s x %s.", self.height_coef, self.width_coef)
158
+
159
+ def info_sequence(self) -> None:
160
+ """Saves generation info to JSON file "generation_info.json".
161
+
162
+ Info sequence contains following attributes:
163
+ - coordinates
164
+ - bbox
165
+ - map_height
166
+ - map_width
167
+ - minimum_x
168
+ - minimum_y
169
+ - maximum_x
170
+ - maximum_y
171
+ - height
172
+ - width
173
+ - height_coef
174
+ - width_coef
175
+ """
176
+ useful_attributes = [
177
+ "coordinates",
178
+ "bbox",
179
+ "map_height",
180
+ "map_width",
181
+ "minimum_x",
182
+ "minimum_y",
183
+ "maximum_x",
184
+ "maximum_y",
185
+ "height",
186
+ "width",
187
+ "height_coef",
188
+ "width_coef",
189
+ ]
190
+ info_sequence = {attr: getattr(self, attr, None) for attr in useful_attributes}
191
+
192
+ with open(self.info_save_path, "w") as f: # pylint: disable=W1514
193
+ json.dump(info_sequence, f, indent=4)
194
+ self.logger.info("Generation info saved to %s.", self.info_save_path)
195
+
196
+ def _prepare_weights(self):
197
+ self.logger.debug("Starting preparing weights from %s layers.", len(self.layers))
198
+
199
+ for layer in self.layers:
200
+ self._generate_weights(layer)
201
+ self.logger.debug("Prepared weights for %s layers.", len(self.layers))
202
+
203
+ def _generate_weights(self, layer: Layer) -> None:
204
+ """Generates weight files for textures. Each file is a numpy array of zeros and
205
+ dtype uint8 (0-255).
206
+
207
+ Args:
208
+ layer (Layer): Layer with textures and tags.
209
+ """
210
+ size = (self.map_height, self.map_width)
211
+ postfix = "_weight.png" if not layer.exclude_weight else ".png"
212
+ if layer.count == 0:
213
+ filepaths = [os.path.join(self._weights_dir, layer.name + postfix)]
214
+ else:
215
+ filepaths = [
216
+ os.path.join(self._weights_dir, layer.name + str(i).zfill(2) + postfix)
217
+ for i in range(1, layer.count + 1)
218
+ ]
219
+
220
+ for filepath in filepaths:
221
+ img = np.zeros(size, dtype=np.uint8)
222
+ cv2.imwrite(filepath, img) # pylint: disable=no-member
223
+
224
+ @property
225
+ def layers(self) -> list[Layer]:
226
+ """Returns list of layers with textures and tags from textures.json.
227
+
228
+ Returns:
229
+ list[Layer]: List of layers.
230
+ """
231
+ return self._layers
232
+
233
+ @layers.setter
234
+ def layers(self, layers: list[Layer]) -> None:
235
+ """Sets list of layers with textures and tags.
236
+
237
+ Args:
238
+ layers (list[Layer]): List of layers.
239
+ """
240
+ self._layers = layers
241
+
242
+ # pylint: disable=no-member
243
+ def draw(self) -> None:
244
+ """Iterates over layers and fills them with polygons from OSM data."""
245
+ for layer in self.layers:
246
+ if not layer.tags:
247
+ self.logger.debug("Layer %s has no tags, there's nothing to draw.", layer.name)
248
+ continue
249
+ layer_path = layer.path(self._weights_dir)
250
+ self.logger.debug("Drawing layer %s.", layer_path)
251
+
252
+ img = cv2.imread(layer_path, cv2.IMREAD_UNCHANGED)
253
+ for polygon in self.polygons(layer.tags, layer.width): # type: ignore
254
+ cv2.fillPoly(img, [polygon], color=255) # type: ignore
255
+ cv2.imwrite(layer_path, img)
256
+ self.logger.debug("Texture %s saved.", layer_path)
257
+
258
+ def get_relative_x(self, x: float) -> int:
259
+ """Converts UTM X coordinate to relative X coordinate in map image.
260
+
261
+ Args:
262
+ x (float): UTM X coordinate.
263
+
264
+ Returns:
265
+ int: Relative X coordinate in map image.
266
+ """
267
+ raw_x = x - self.minimum_x
268
+ return int(raw_x * self.height_coef)
269
+
270
+ def get_relative_y(self, y: float) -> int:
271
+ """Converts UTM Y coordinate to relative Y coordinate in map image.
272
+
273
+ Args:
274
+ y (float): UTM Y coordinate.
275
+
276
+ Returns:
277
+ int: Relative Y coordinate in map image.
278
+ """
279
+ raw_y = y - self.minimum_y
280
+ return self.height - int(raw_y * self.width_coef)
281
+
282
+ # pylint: disable=W0613
283
+ def _to_np(self, geometry: shapely.geometry.polygon.Polygon, *args) -> np.ndarray:
284
+ """Converts Polygon geometry to numpy array of polygon points.
285
+
286
+ Args:
287
+ geometry (shapely.geometry.polygon.Polygon): Polygon geometry.
288
+ *args: Additional arguments:
289
+ - width (int | None): Width of the polygon in meters.
290
+
291
+ Returns:
292
+ np.ndarray: Numpy array of polygon points.
293
+ """
294
+ xs, ys = geometry.exterior.coords.xy
295
+ xs = [int(self.get_relative_x(x)) for x in xs.tolist()]
296
+ ys = [int(self.get_relative_y(y)) for y in ys.tolist()]
297
+ pairs = list(zip(xs, ys))
298
+ return np.array(pairs, dtype=np.int32).reshape((-1, 1, 2))
299
+
300
+ def _to_polygon(self, obj: pd.core.series.Series, width: int | None) -> np.ndarray | None:
301
+ """Converts OSM object to numpy array of polygon points.
302
+
303
+ Args:
304
+ obj (pd.core.series.Series): OSM object.
305
+ width (int | None): Width of the polygon in meters.
306
+
307
+ Returns:
308
+ np.ndarray | None: Numpy array of polygon points.
309
+ """
310
+ geometry = obj["geometry"]
311
+ geometry_type = geometry.geom_type
312
+ converter = self._converters(geometry_type)
313
+ if not converter:
314
+ self.logger.warning("Geometry type %s not supported.", geometry_type)
315
+ return None
316
+ return converter(geometry, width)
317
+
318
+ def _sequence(
319
+ self,
320
+ geometry: shapely.geometry.linestring.LineString | shapely.geometry.point.Point,
321
+ width: int | None,
322
+ ) -> np.ndarray:
323
+ """Converts LineString or Point geometry to numpy array of polygon points.
324
+
325
+ Args:
326
+ geometry (shapely.geometry.linestring.LineString | shapely.geometry.point.Point):
327
+ LineString or Point geometry.
328
+ width (int | None): Width of the polygon in meters.
329
+
330
+ Returns:
331
+ np.ndarray: Numpy array of polygon points.
332
+ """
333
+ polygon = geometry.buffer(width)
334
+ return self._to_np(polygon)
335
+
336
+ def _converters(
337
+ self, geom_type: str
338
+ ) -> Optional[Callable[[BaseGeometry, Optional[int]], np.ndarray]]:
339
+ """Returns a converter function for a given geometry type.
340
+
341
+ Args:
342
+ geom_type (str): Geometry type.
343
+
344
+ Returns:
345
+ Callable[[shapely.geometry, int | None], np.ndarray]: Converter function.
346
+ """
347
+ converters = {"Polygon": self._to_np, "LineString": self._sequence, "Point": self._sequence}
348
+ return converters.get(geom_type) # type: ignore
349
+
350
+ def polygons(
351
+ self, tags: dict[str, str | list[str] | bool], width: int | None
352
+ ) -> Generator[np.ndarray, None, None]:
353
+ """Generator which yields numpy arrays of polygons from OSM data.
354
+
355
+ Args:
356
+ tags (dict[str, str | list[str]]): Dictionary of tags to search for.
357
+ width (int | None): Width of the polygon in meters (only for LineString).
358
+
359
+ Yields:
360
+ Generator[np.ndarray, None, None]: Numpy array of polygon points.
361
+ """
362
+ try:
363
+ with warnings.catch_warnings():
364
+ warnings.simplefilter("ignore", DeprecationWarning)
365
+ objects = ox.features_from_bbox(bbox=self.bbox, tags=tags)
366
+ except Exception as e: # pylint: disable=W0718
367
+ self.logger.warning("Error fetching objects for tags: %s.", tags)
368
+ self.logger.warning(e)
369
+ return
370
+ objects_utm = ox.project_gdf(objects, to_latlong=False)
371
+ self.logger.debug("Fetched %s elements for tags: %s.", len(objects_utm), tags)
372
+
373
+ for _, obj in objects_utm.iterrows():
374
+ polygon = self._to_polygon(obj, width)
375
+ if polygon is None:
376
+ continue
377
+ yield polygon
378
+
379
+ def previews(self) -> list[str]:
380
+ """Invokes methods to generate previews. Returns list of paths to previews.
381
+
382
+ Returns:
383
+ list[str]: List of paths to previews.
384
+ """
385
+ preview_paths = []
386
+ preview_paths.append(self._osm_preview())
387
+ return preview_paths
388
+
389
+ # pylint: disable=no-member
390
+ def _osm_preview(self) -> str:
391
+ """Merges layers into one image and saves it into the png file.
392
+
393
+ Returns:
394
+ str: Path to the preview.
395
+ """
396
+ scaling_factor = min(
397
+ PREVIEW_MAXIMUM_SIZE / self.map_width, PREVIEW_MAXIMUM_SIZE / self.map_height
398
+ )
399
+
400
+ preview_size = (
401
+ int(self.map_width * scaling_factor),
402
+ int(self.map_height * scaling_factor),
403
+ )
404
+ self.logger.debug(
405
+ "Scaling factor: %s. Preview size: %s.",
406
+ scaling_factor,
407
+ preview_size,
408
+ )
409
+
410
+ active_layers = [layer for layer in self.layers if layer.tags is not None]
411
+ self.logger.debug("Following layers have tag textures: %s.", len(active_layers))
412
+
413
+ images = [
414
+ cv2.resize(
415
+ cv2.imread(layer.path(self._weights_dir), cv2.IMREAD_UNCHANGED), preview_size
416
+ )
417
+ for layer in active_layers
418
+ ]
419
+ colors = [layer.color for layer in active_layers]
420
+ color_images = []
421
+ for img, color in zip(images, colors):
422
+ color_img = np.zeros((img.shape[0], img.shape[1], 3), dtype=np.uint8)
423
+ color_img[img > 0] = color
424
+ color_images.append(color_img)
425
+ merged = np.sum(color_images, axis=0, dtype=np.uint8)
426
+ self.logger.debug(
427
+ "Merged layers into one image. Shape: %s, dtype: %s.",
428
+ merged.shape,
429
+ merged.dtype,
430
+ )
431
+ preview_path = os.path.join(self.map_directory, "preview_osm.png")
432
+ cv2.imwrite(preview_path, merged) # pylint: disable=no-member
433
+ self.logger.info("Preview saved to %s.", preview_path)
434
+ return preview_path
maps4fs/logger.py ADDED
@@ -0,0 +1,46 @@
1
+ """This module contains the Logger class for logging to the file and stdout."""
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+ from datetime import datetime
7
+ from typing import Literal
8
+
9
+ log_directory = os.path.join(os.getcwd(), "logs")
10
+ os.makedirs(log_directory, exist_ok=True)
11
+
12
+
13
+ class Logger(logging.Logger):
14
+ """Handles logging to the file and stroudt with timestamps."""
15
+
16
+ def __init__(
17
+ self,
18
+ name: str,
19
+ level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "ERROR",
20
+ to_stdout: bool = True,
21
+ to_file: bool = True,
22
+ ):
23
+ super().__init__(name)
24
+ self.setLevel(level)
25
+ self.stdout_handler = logging.StreamHandler(sys.stdout)
26
+ self.file_handler = logging.FileHandler(
27
+ filename=self.log_file(), mode="a", encoding="utf-8"
28
+ )
29
+ formatter = "%(name)s | %(levelname)s | %(asctime)s | %(message)s"
30
+ self.fmt = formatter
31
+ self.stdout_handler.setFormatter(logging.Formatter(formatter))
32
+ self.file_handler.setFormatter(logging.Formatter(formatter))
33
+ if to_stdout:
34
+ self.addHandler(self.stdout_handler)
35
+ if to_file:
36
+ self.addHandler(self.file_handler)
37
+
38
+ def log_file(self) -> str:
39
+ """Returns the path to the log file.
40
+
41
+ Returns:
42
+ str: The path to the log file.
43
+ """
44
+ today = datetime.now().strftime("%Y-%m-%d")
45
+ log_file = os.path.join(log_directory, f"{today}.txt")
46
+ return log_file
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright © [2024] [iwatkot]
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.