maps4fs 0.9.8__py3-none-any.whl → 1.1.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.
@@ -7,8 +7,11 @@ import os
7
7
  from copy import deepcopy
8
8
  from typing import TYPE_CHECKING, Any
9
9
 
10
+ import cv2
10
11
  import osmnx as ox # type: ignore
11
12
  from pyproj import Transformer
13
+ from shapely.affinity import rotate, translate # type: ignore
14
+ from shapely.geometry import Polygon, box # type: ignore
12
15
 
13
16
  from maps4fs.generator.qgis import save_scripts
14
17
 
@@ -16,15 +19,16 @@ if TYPE_CHECKING:
16
19
  from maps4fs.generator.game import Game
17
20
 
18
21
 
19
- # pylint: disable=R0801, R0903, R0902
22
+ # pylint: disable=R0801, R0903, R0902, R0904
20
23
  class Component:
21
24
  """Base class for all map generation components.
22
25
 
23
- Args:
26
+ Arguments:
24
27
  game (Game): The game instance for which the map is generated.
25
28
  coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
26
- map_height (int): The height of the map in pixels.
27
- map_width (int): The width of the map in pixels.
29
+ map_size (int): The size of the map in pixels.
30
+ map_rotated_size (int): The size of the map in pixels after rotation.
31
+ rotation (int): The rotation angle of the map.
28
32
  map_directory (str): The directory where the map files are stored.
29
33
  logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
30
34
  info, warning. If not provided, default logging will be used.
@@ -34,22 +38,25 @@ class Component:
34
38
  self,
35
39
  game: Game,
36
40
  coordinates: tuple[float, float],
37
- map_height: int,
38
- map_width: int,
41
+ map_size: int,
42
+ map_rotated_size: int,
43
+ rotation: int,
39
44
  map_directory: str,
40
45
  logger: Any = None,
41
46
  **kwargs, # pylint: disable=W0613, R0913, R0917
42
47
  ):
43
48
  self.game = game
44
49
  self.coordinates = coordinates
45
- self.map_height = map_height
46
- self.map_width = map_width
50
+ self.map_size = map_size
51
+ self.map_rotated_size = map_rotated_size
52
+ self.rotation = rotation
47
53
  self.map_directory = map_directory
48
54
  self.logger = logger
49
55
  self.kwargs = kwargs
50
56
 
51
57
  os.makedirs(self.previews_directory, exist_ok=True)
52
58
  os.makedirs(self.scripts_directory, exist_ok=True)
59
+ os.makedirs(self.info_layers_directory, exist_ok=True)
53
60
 
54
61
  self.save_bbox()
55
62
  self.preprocess()
@@ -87,6 +94,15 @@ class Component:
87
94
  """
88
95
  return os.path.join(self.map_directory, "previews")
89
96
 
97
+ @property
98
+ def info_layers_directory(self) -> str:
99
+ """The directory where the info layers are stored.
100
+
101
+ Returns:
102
+ str: The directory where the info layers are stored.
103
+ """
104
+ return os.path.join(self.map_directory, "info_layers")
105
+
90
106
  @property
91
107
  def scripts_directory(self) -> str:
92
108
  """The directory where the scripts are stored.
@@ -123,7 +139,7 @@ class Component:
123
139
  """Updates the generation info with the provided data.
124
140
  If the generation info file does not exist, it will be created.
125
141
 
126
- Args:
142
+ Arguments:
127
143
  data (dict[Any, Any]): The data to update the generation info with.
128
144
  """
129
145
  if os.path.isfile(self.generation_info_path):
@@ -150,45 +166,37 @@ class Component:
150
166
  def get_bbox(
151
167
  self,
152
168
  coordinates: tuple[float, float] | None = None,
153
- height_distance: int | None = None,
154
- width_distance: int | None = None,
169
+ distance: int | None = None,
155
170
  project_utm: bool = False,
156
171
  ) -> tuple[int, int, int, int]:
157
172
  """Calculates the bounding box of the map from the coordinates and the height and
158
173
  width of the map.
159
174
  If coordinates and distance are not provided, the instance variables are used.
160
175
 
161
- Args:
162
- coordinates (tuple[float, float], optional): The latitude and longitude of the center of
163
- the map. Defaults to None.
164
- height_distance (int, optional): The distance from the center of the map to the edge of
165
- the map in the north-south direction. Defaults to None.
166
- width_distance (int, optional): The distance from the center of the map to the edge of
167
- the map in the east-west direction. Defaults to None.
176
+ Arguments:
177
+ coordinates (tuple[float, float], optional): The latitude and longitude of the center
178
+ of the map. Defaults to None.
179
+ distance (int, optional): The distance from the center of the map to the edge of the
180
+ map in all directions. Defaults to None.
168
181
  project_utm (bool, optional): Whether to project the bounding box to UTM.
169
182
 
170
183
  Returns:
171
184
  tuple[int, int, int, int]: The bounding box of the map.
172
185
  """
173
186
  coordinates = coordinates or self.coordinates
174
- height_distance = height_distance or int(self.map_height / 2)
175
- width_distance = width_distance or int(self.map_width / 2)
187
+ distance = distance or int(self.map_rotated_size / 2)
176
188
 
177
- north, south, _, _ = ox.utils_geo.bbox_from_point(
178
- coordinates, dist=height_distance, project_utm=project_utm
179
- )
180
- _, _, east, west = ox.utils_geo.bbox_from_point(
181
- coordinates, dist=width_distance, project_utm=project_utm
189
+ west, south, east, north = ox.utils_geo.bbox_from_point( # type: ignore
190
+ coordinates, dist=distance, project_utm=project_utm
182
191
  )
192
+
183
193
  bbox = north, south, east, west
184
194
  self.logger.debug(
185
- "Calculated bounding box for component: %s: %s, project_utm: %s, "
186
- "height_distance: %s, width_distance: %s",
195
+ "Calculated bounding box for component: %s: %s, project_utm: %s, distance: %s",
187
196
  self.__class__.__name__,
188
197
  bbox,
189
198
  project_utm,
190
- height_distance,
191
- width_distance,
199
+ distance,
192
200
  )
193
201
  return bbox
194
202
 
@@ -219,7 +227,7 @@ class Component:
219
227
  """Converts the bounding box to EPSG:3857.
220
228
  If the bounding box is not provided, the instance variable is used.
221
229
 
222
- Args:
230
+ Arguments:
223
231
  bbox (tuple[float, float, float, float], optional): The bounding box to convert.
224
232
  add_margin (bool, optional): Whether to add a margin to the bounding box.
225
233
 
@@ -247,7 +255,7 @@ class Component:
247
255
  """Converts the bounding box to EPSG:3857 string.
248
256
  If the bounding box is not provided, the instance variable is used.
249
257
 
250
- Args:
258
+ Arguments:
251
259
  bbox (tuple[float, float, float, float], optional): The bounding box to convert.
252
260
  add_margin (bool, optional): Whether to add a margin to the bounding box.
253
261
 
@@ -265,9 +273,184 @@ class Component:
265
273
  bounding box coordinates in EPSG:3857.
266
274
  For filenames, the class name is used as a prefix.
267
275
 
268
- Args:
276
+ Arguments:
269
277
  qgis_layers (list[tuple[str, float, float, float, float]]): The list of layers to
270
278
  create scripts for.
271
279
  """
272
280
  class_name = self.__class__.__name__.lower()
273
281
  save_scripts(qgis_layers, class_name, self.scripts_directory)
282
+
283
+ def get_polygon_center(self, polygon_points: list[tuple[int, int]]) -> tuple[int, int]:
284
+ """Calculates the center of a polygon defined by a list of points.
285
+
286
+ Arguments:
287
+ polygon_points (list[tuple[int, int]]): The points of the polygon.
288
+
289
+ Returns:
290
+ tuple[int, int]: The center of the polygon.
291
+ """
292
+ polygon = Polygon(polygon_points)
293
+ center = polygon.centroid
294
+ return int(center.x), int(center.y)
295
+
296
+ def absolute_to_relative(
297
+ self, point: tuple[int, int], center: tuple[int, int]
298
+ ) -> tuple[int, int]:
299
+ """Converts a pair of absolute coordinates to relative coordinates.
300
+
301
+ Arguments:
302
+ point (tuple[int, int]): The absolute coordinates.
303
+ center (tuple[int, int]): The center coordinates.
304
+
305
+ Returns:
306
+ tuple[int, int]: The relative coordinates.
307
+ """
308
+ cx, cy = center
309
+ x, y = point
310
+ return x - cx, y - cy
311
+
312
+ def top_left_coordinates_to_center(self, top_left: tuple[int, int]) -> tuple[int, int]:
313
+ """Converts a pair of coordinates from the top-left system to the center system.
314
+ In top-left system, the origin (0, 0) is in the top-left corner of the map, while in the
315
+ center system, the origin is in the center of the map.
316
+
317
+ Arguments:
318
+ top_left (tuple[int, int]): The coordinates in the top-left system.
319
+
320
+ Returns:
321
+ tuple[int, int]: The coordinates in the center system.
322
+ """
323
+ x, y = top_left
324
+ cs_x = x - self.map_size // 2
325
+ cs_y = y - self.map_size // 2
326
+
327
+ return cs_x, cs_y
328
+
329
+ def fit_polygon_into_bounds(
330
+ self, polygon_points: list[tuple[int, int]], margin: int = 0, angle: int = 0
331
+ ) -> list[tuple[int, int]]:
332
+ """Fits a polygon into the bounds of the map.
333
+
334
+ Arguments:
335
+ polygon_points (list[tuple[int, int]]): The points of the polygon.
336
+ margin (int, optional): The margin to add to the polygon. Defaults to 0.
337
+ angle (int, optional): The angle to rotate the polygon by. Defaults to 0.
338
+
339
+ Returns:
340
+ list[tuple[int, int]]: The points of the polygon fitted into the map bounds.
341
+ """
342
+ min_x = min_y = 0
343
+ max_x = max_y = self.map_size
344
+
345
+ polygon = Polygon(polygon_points)
346
+
347
+ if angle:
348
+ center_x = center_y = self.map_rotated_size // 2
349
+ self.logger.debug(
350
+ "Rotating the polygon by %s degrees with center at %sx%s",
351
+ angle,
352
+ center_x,
353
+ center_y,
354
+ )
355
+ polygon = rotate(polygon, -angle, origin=(center_x, center_y))
356
+ offset = (self.map_size / 2) - (self.map_rotated_size / 2)
357
+ self.logger.debug("Translating the polygon by %s", offset)
358
+ polygon = translate(polygon, xoff=offset, yoff=offset)
359
+
360
+ if margin:
361
+ polygon = polygon.buffer(margin, join_style="mitre")
362
+ if polygon.is_empty:
363
+ raise ValueError("The polygon is empty after adding the margin.")
364
+
365
+ # Create a bounding box for the map bounds
366
+ bounds = box(min_x, min_y, max_x, max_y)
367
+
368
+ # Intersect the polygon with the bounds to fit it within the map
369
+ fitted_polygon = polygon.intersection(bounds)
370
+
371
+ if not isinstance(fitted_polygon, Polygon):
372
+ raise ValueError("The fitted polygon is not a valid polygon.")
373
+
374
+ # Return the fitted polygon points
375
+ return list(fitted_polygon.exterior.coords)
376
+
377
+ def get_infolayer_path(self, layer_name: str) -> str | None:
378
+ """Returns the path to the info layer file.
379
+
380
+ Arguments:
381
+ layer_name (str): The name of the layer.
382
+
383
+ Returns:
384
+ str | None: The path to the info layer file or None if the layer does not exist.
385
+ """
386
+ info_layer_path = os.path.join(self.info_layers_directory, f"{layer_name}.json")
387
+ if not os.path.isfile(info_layer_path):
388
+ self.logger.warning("Info layer %s does not exist", info_layer_path)
389
+ return None
390
+ return info_layer_path
391
+
392
+ # pylint: disable=R0913, R0917, R0914
393
+ def rotate_image(
394
+ self,
395
+ image_path: str,
396
+ angle: int,
397
+ output_height: int,
398
+ output_width: int,
399
+ output_path: str | None = None,
400
+ ) -> None:
401
+ """Rotates an image by a given angle around its center and cuts out the center to match
402
+ the output size.
403
+
404
+ Arguments:
405
+ image_path (str): The path to the image to rotate.
406
+ angle (int): The angle to rotate the image by.
407
+ output_height (int): The height of the output image.
408
+ output_width (int): The width of the output image.
409
+ """
410
+ if not os.path.isfile(image_path):
411
+ self.logger.warning("Image %s does not exist", image_path)
412
+ return
413
+
414
+ # pylint: disable=no-member
415
+ image = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
416
+ if image is None:
417
+ self.logger.warning("Image %s could not be read", image_path)
418
+ return
419
+
420
+ self.logger.debug("Read image from %s with shape: %s", image_path, image.shape)
421
+
422
+ if not output_path:
423
+ output_path = image_path
424
+
425
+ height, width = image.shape[:2]
426
+ center = (width // 2, height // 2)
427
+
428
+ self.logger.debug(
429
+ "Rotating the image... Angle: %s, center: %s, height: %s, width: %s",
430
+ angle,
431
+ center,
432
+ height,
433
+ width,
434
+ )
435
+
436
+ rotation_matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
437
+ rotated = cv2.warpAffine(image, rotation_matrix, (width, height))
438
+
439
+ start_x = center[0] - output_width // 2
440
+ start_y = center[1] - output_height // 2
441
+ end_x = start_x + output_width
442
+ end_y = start_y + output_height
443
+
444
+ self.logger.debug(
445
+ "Cropping the rotated image: start_x: %s, start_y: %s, end_x: %s, end_y: %s",
446
+ start_x,
447
+ start_y,
448
+ end_x,
449
+ end_y,
450
+ )
451
+
452
+ cropped = rotated[start_y:end_y, start_x:end_x]
453
+
454
+ self.logger.debug("Shape of the cropped image: %s", cropped.shape)
455
+
456
+ cv2.imwrite(output_path, cropped)
@@ -12,10 +12,12 @@ from maps4fs.generator.component import Component
12
12
  class Config(Component):
13
13
  """Component for map settings and configuration.
14
14
 
15
- Args:
15
+ Arguments:
16
+ game (Game): The game instance for which the map is generated.
16
17
  coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
17
- map_height (int): The height of the map in pixels.
18
- map_width (int): The width of the map in pixels.
18
+ map_size (int): The size of the map in pixels (it's a square).
19
+ map_rotated_size (int): The size of the map in pixels after rotation.
20
+ rotation (int): The rotation angle of the map.
19
21
  map_directory (str): The directory where the map files are stored.
20
22
  logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
21
23
  info, warning. If not provided, default logging will be used.
@@ -36,13 +38,15 @@ class Config(Component):
36
38
  self.logger.warning("Map XML file not found: %s.", self._map_xml_path)
37
39
  return
38
40
  tree = ET.parse(self._map_xml_path)
39
- self.logger.debug("Map XML file loaded from: %s.", self._map_xml_path)
41
+ self.logger.info("Map XML file loaded from: %s.", self._map_xml_path)
40
42
  root = tree.getroot()
41
43
  for map_elem in root.iter("map"):
42
- map_elem.set("width", str(self.map_width))
43
- map_elem.set("height", str(self.map_height))
44
+ map_elem.set("width", str(self.map_size))
45
+ map_elem.set("height", str(self.map_size))
44
46
  self.logger.debug(
45
- "Map size set to %sx%s in Map XML file.", self.map_width, self.map_height
47
+ "Map size set to %sx%s in Map XML file.",
48
+ self.map_size,
49
+ self.map_size,
46
50
  )
47
51
  tree.write(self._map_xml_path)
48
52
  self.logger.debug("Map XML file saved to: %s.", self._map_xml_path)
@@ -67,7 +71,7 @@ class Config(Component):
67
71
  # if the map is 2048x2048 or 4096x4096, the overview will be 4096x4096
68
72
  # and the map will be in the center of the overview.
69
73
  # That's why the distance is set to the map height not as a half of it.
70
- bbox = self.get_bbox(height_distance=self.map_height, width_distance=self.map_width)
74
+ bbox = self.get_bbox(distance=self.map_size)
71
75
  south, west, north, east = bbox
72
76
  epsg3857_string = self.get_epsg3857_string(bbox=bbox)
73
77
  epsg3857_string_with_margin = self.get_epsg3857_string(bbox=bbox, add_margin=True)
@@ -81,8 +85,8 @@ class Config(Component):
81
85
  "west": west,
82
86
  "north": north,
83
87
  "east": east,
84
- "height": self.map_height * 2,
85
- "width": self.map_width * 2,
88
+ "height": self.map_size * 2,
89
+ "width": self.map_size * 2,
86
90
  }
87
91
 
88
92
  data = {
@@ -93,7 +97,7 @@ class Config(Component):
93
97
 
94
98
  def qgis_sequence(self) -> None:
95
99
  """Generates QGIS scripts for creating bounding box layers and rasterizing them."""
96
- bbox = self.get_bbox(height_distance=self.map_height, width_distance=self.map_width)
100
+ bbox = self.get_bbox(distance=self.map_size)
97
101
  espg3857_bbox = self.get_espg3857_bbox(bbox=bbox)
98
102
  espg3857_bbox_with_margin = self.get_espg3857_bbox(bbox=bbox, add_margin=True)
99
103