maps4fs 0.9.93__py3-none-any.whl → 1.1.6__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.
- maps4fs/generator/background.py +279 -227
- maps4fs/generator/component.py +220 -32
- maps4fs/generator/config.py +15 -11
- maps4fs/generator/dem.py +81 -98
- maps4fs/generator/game.py +19 -3
- maps4fs/generator/grle.py +186 -0
- maps4fs/generator/i3d.py +239 -25
- maps4fs/generator/map.py +29 -18
- maps4fs/generator/qgis.py +5 -5
- maps4fs/generator/texture.py +233 -38
- maps4fs/logger.py +1 -25
- maps4fs/toolbox/background.py +63 -0
- maps4fs/toolbox/dem.py +3 -3
- maps4fs-1.1.6.dist-info/LICENSE.md +190 -0
- {maps4fs-0.9.93.dist-info → maps4fs-1.1.6.dist-info}/METADATA +111 -58
- maps4fs-1.1.6.dist-info/RECORD +21 -0
- maps4fs/generator/path_steps.py +0 -83
- maps4fs/generator/tile.py +0 -55
- maps4fs-0.9.93.dist-info/LICENSE.md +0 -21
- maps4fs-0.9.93.dist-info/RECORD +0 -21
- {maps4fs-0.9.93.dist-info → maps4fs-1.1.6.dist-info}/WHEEL +0 -0
- {maps4fs-0.9.93.dist-info → maps4fs-1.1.6.dist-info}/top_level.txt +0 -0
maps4fs/generator/component.py
CHANGED
@@ -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
|
-
|
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
|
-
|
27
|
-
|
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
|
-
|
38
|
-
|
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.
|
46
|
-
self.
|
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
|
-
|
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
|
-
|
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
|
-
|
162
|
-
coordinates (tuple[float, float], optional): The latitude and longitude of the center
|
163
|
-
the map. Defaults to None.
|
164
|
-
|
165
|
-
|
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
|
-
|
175
|
-
width_distance = width_distance or int(self.map_width / 2)
|
187
|
+
distance = distance or int(self.map_rotated_size / 2)
|
176
188
|
|
177
|
-
|
178
|
-
coordinates, dist=
|
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
|
-
|
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
|
-
|
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
|
-
|
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,189 @@ 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
|
-
|
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
|
+
self.logger.debug("Rotated and translated polygon.")
|
360
|
+
|
361
|
+
if margin:
|
362
|
+
polygon = polygon.buffer(margin, join_style="mitre")
|
363
|
+
if polygon.is_empty:
|
364
|
+
raise ValueError("The polygon is empty after adding the margin.")
|
365
|
+
|
366
|
+
# Create a bounding box for the map bounds
|
367
|
+
bounds = box(min_x, min_y, max_x, max_y)
|
368
|
+
|
369
|
+
# Intersect the polygon with the bounds to fit it within the map
|
370
|
+
fitted_polygon = polygon.intersection(bounds)
|
371
|
+
self.logger.debug("Fitted the polygon into the bounds: %s", bounds)
|
372
|
+
|
373
|
+
if not isinstance(fitted_polygon, Polygon):
|
374
|
+
raise ValueError("The fitted polygon is not a valid polygon.")
|
375
|
+
|
376
|
+
# Return the fitted polygon points
|
377
|
+
as_list = list(fitted_polygon.exterior.coords)
|
378
|
+
if not as_list:
|
379
|
+
raise ValueError("The fitted polygon has no points.")
|
380
|
+
return as_list
|
381
|
+
|
382
|
+
def get_infolayer_path(self, layer_name: str) -> str | None:
|
383
|
+
"""Returns the path to the info layer file.
|
384
|
+
|
385
|
+
Arguments:
|
386
|
+
layer_name (str): The name of the layer.
|
387
|
+
|
388
|
+
Returns:
|
389
|
+
str | None: The path to the info layer file or None if the layer does not exist.
|
390
|
+
"""
|
391
|
+
info_layer_path = os.path.join(self.info_layers_directory, f"{layer_name}.json")
|
392
|
+
if not os.path.isfile(info_layer_path):
|
393
|
+
self.logger.warning("Info layer %s does not exist", info_layer_path)
|
394
|
+
return None
|
395
|
+
return info_layer_path
|
396
|
+
|
397
|
+
# pylint: disable=R0913, R0917, R0914
|
398
|
+
def rotate_image(
|
399
|
+
self,
|
400
|
+
image_path: str,
|
401
|
+
angle: int,
|
402
|
+
output_height: int,
|
403
|
+
output_width: int,
|
404
|
+
output_path: str | None = None,
|
405
|
+
) -> None:
|
406
|
+
"""Rotates an image by a given angle around its center and cuts out the center to match
|
407
|
+
the output size.
|
408
|
+
|
409
|
+
Arguments:
|
410
|
+
image_path (str): The path to the image to rotate.
|
411
|
+
angle (int): The angle to rotate the image by.
|
412
|
+
output_height (int): The height of the output image.
|
413
|
+
output_width (int): The width of the output image.
|
414
|
+
"""
|
415
|
+
if not os.path.isfile(image_path):
|
416
|
+
self.logger.warning("Image %s does not exist", image_path)
|
417
|
+
return
|
418
|
+
|
419
|
+
# pylint: disable=no-member
|
420
|
+
image = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
|
421
|
+
if image is None:
|
422
|
+
self.logger.warning("Image %s could not be read", image_path)
|
423
|
+
return
|
424
|
+
|
425
|
+
self.logger.debug("Read image from %s with shape: %s", image_path, image.shape)
|
426
|
+
|
427
|
+
if not output_path:
|
428
|
+
output_path = image_path
|
429
|
+
|
430
|
+
height, width = image.shape[:2]
|
431
|
+
center = (width // 2, height // 2)
|
432
|
+
|
433
|
+
self.logger.debug(
|
434
|
+
"Rotating the image... Angle: %s, center: %s, height: %s, width: %s",
|
435
|
+
angle,
|
436
|
+
center,
|
437
|
+
height,
|
438
|
+
width,
|
439
|
+
)
|
440
|
+
|
441
|
+
rotation_matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
|
442
|
+
rotated = cv2.warpAffine(image, rotation_matrix, (width, height))
|
443
|
+
|
444
|
+
start_x = center[0] - output_width // 2
|
445
|
+
start_y = center[1] - output_height // 2
|
446
|
+
end_x = start_x + output_width
|
447
|
+
end_y = start_y + output_height
|
448
|
+
|
449
|
+
self.logger.debug(
|
450
|
+
"Cropping the rotated image: start_x: %s, start_y: %s, end_x: %s, end_y: %s",
|
451
|
+
start_x,
|
452
|
+
start_y,
|
453
|
+
end_x,
|
454
|
+
end_y,
|
455
|
+
)
|
456
|
+
|
457
|
+
cropped = rotated[start_y:end_y, start_x:end_x]
|
458
|
+
|
459
|
+
self.logger.debug("Shape of the cropped image: %s", cropped.shape)
|
460
|
+
|
461
|
+
cv2.imwrite(output_path, cropped)
|
maps4fs/generator/config.py
CHANGED
@@ -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
|
-
|
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
|
-
|
18
|
-
|
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.
|
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.
|
43
|
-
map_elem.set("height", str(self.
|
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.",
|
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(
|
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.
|
85
|
-
"width": self.
|
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(
|
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
|
|