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.
- maps4fs/__init__.py +22 -0
- maps4fs/generator/__init__.py +1 -0
- maps4fs/generator/background.py +625 -0
- maps4fs/generator/component.py +553 -0
- maps4fs/generator/config.py +109 -0
- maps4fs/generator/dem.py +297 -0
- maps4fs/generator/dtm/__init__.py +0 -0
- maps4fs/generator/dtm/base/wcs.py +71 -0
- maps4fs/generator/dtm/base/wms.py +70 -0
- maps4fs/generator/dtm/bavaria.py +113 -0
- maps4fs/generator/dtm/dtm.py +637 -0
- maps4fs/generator/dtm/england.py +31 -0
- maps4fs/generator/dtm/hessen.py +31 -0
- maps4fs/generator/dtm/niedersachsen.py +39 -0
- maps4fs/generator/dtm/nrw.py +30 -0
- maps4fs/generator/dtm/srtm.py +127 -0
- maps4fs/generator/dtm/usgs.py +87 -0
- maps4fs/generator/dtm/utils.py +61 -0
- maps4fs/generator/game.py +247 -0
- maps4fs/generator/grle.py +470 -0
- maps4fs/generator/i3d.py +624 -0
- maps4fs/generator/map.py +275 -0
- maps4fs/generator/qgis.py +196 -0
- maps4fs/generator/satellite.py +92 -0
- maps4fs/generator/settings.py +187 -0
- maps4fs/generator/texture.py +893 -0
- maps4fs/logger.py +46 -0
- maps4fs/toolbox/__init__.py +1 -0
- maps4fs/toolbox/background.py +63 -0
- maps4fs/toolbox/custom_osm.py +67 -0
- maps4fs/toolbox/dem.py +112 -0
- maps4fs-1.8.0.dist-info/LICENSE.md +190 -0
- maps4fs-1.8.0.dist-info/METADATA +693 -0
- maps4fs-1.8.0.dist-info/RECORD +36 -0
- maps4fs-1.8.0.dist-info/WHEEL +5 -0
- maps4fs-1.8.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,553 @@
|
|
1
|
+
"""This module contains the base class for all map generation components."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import json
|
6
|
+
import os
|
7
|
+
from copy import deepcopy
|
8
|
+
from typing import TYPE_CHECKING, Any
|
9
|
+
|
10
|
+
import cv2 # type: ignore
|
11
|
+
import osmnx as ox # type: ignore
|
12
|
+
from pyproj import Transformer
|
13
|
+
from shapely.affinity import rotate, translate # type: ignore
|
14
|
+
from shapely.geometry import LineString, Polygon, box # type: ignore
|
15
|
+
|
16
|
+
from maps4fs.generator.qgis import save_scripts
|
17
|
+
|
18
|
+
if TYPE_CHECKING:
|
19
|
+
from maps4fs.generator.game import Game
|
20
|
+
from maps4fs.generator.map import Map
|
21
|
+
|
22
|
+
|
23
|
+
# pylint: disable=R0801, R0903, R0902, R0904, R0913, R0917
|
24
|
+
class Component:
|
25
|
+
"""Base class for all map generation components.
|
26
|
+
|
27
|
+
Arguments:
|
28
|
+
game (Game): The game instance for which the map is generated.
|
29
|
+
map (Map): The map instance for which the component is generated.
|
30
|
+
coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
|
31
|
+
map_size (int): The size of the map in pixels.
|
32
|
+
map_rotated_size (int): The size of the map in pixels after rotation.
|
33
|
+
rotation (int): The rotation angle of the map.
|
34
|
+
map_directory (str): The directory where the map files are stored.
|
35
|
+
logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
|
36
|
+
info, warning. If not provided, default logging will be used.
|
37
|
+
"""
|
38
|
+
|
39
|
+
def __init__(
|
40
|
+
self,
|
41
|
+
game: Game,
|
42
|
+
map: Map, # pylint: disable=W0622
|
43
|
+
coordinates: tuple[float, float],
|
44
|
+
map_size: int,
|
45
|
+
map_rotated_size: int,
|
46
|
+
rotation: int,
|
47
|
+
map_directory: str,
|
48
|
+
logger: Any = None,
|
49
|
+
**kwargs: dict[str, Any],
|
50
|
+
):
|
51
|
+
self.game = game
|
52
|
+
self.map = map
|
53
|
+
self.coordinates = coordinates
|
54
|
+
self.map_size = map_size
|
55
|
+
self.map_rotated_size = map_rotated_size
|
56
|
+
self.rotation = rotation
|
57
|
+
self.map_directory = map_directory
|
58
|
+
self.logger = logger
|
59
|
+
self.kwargs = kwargs
|
60
|
+
|
61
|
+
self.logger.debug(
|
62
|
+
"Component %s initialized. Map size: %s, map rotated size: %s", # type: ignore
|
63
|
+
self.__class__.__name__,
|
64
|
+
self.map_size,
|
65
|
+
self.map_rotated_size,
|
66
|
+
)
|
67
|
+
|
68
|
+
os.makedirs(self.previews_directory, exist_ok=True)
|
69
|
+
os.makedirs(self.scripts_directory, exist_ok=True)
|
70
|
+
os.makedirs(self.info_layers_directory, exist_ok=True)
|
71
|
+
os.makedirs(self.satellite_directory, exist_ok=True)
|
72
|
+
|
73
|
+
self.save_bbox()
|
74
|
+
self.preprocess()
|
75
|
+
|
76
|
+
def preprocess(self) -> None:
|
77
|
+
"""Prepares the component for processing. Must be implemented in the child class.
|
78
|
+
|
79
|
+
Raises:
|
80
|
+
NotImplementedError: If the method is not implemented in the child class.
|
81
|
+
"""
|
82
|
+
raise NotImplementedError
|
83
|
+
|
84
|
+
def process(self) -> None:
|
85
|
+
"""Launches the component processing. Must be implemented in the child class.
|
86
|
+
|
87
|
+
Raises:
|
88
|
+
NotImplementedError: If the method is not implemented in the child class.
|
89
|
+
"""
|
90
|
+
raise NotImplementedError
|
91
|
+
|
92
|
+
def previews(self) -> list[str]:
|
93
|
+
"""Returns a list of paths to the preview images. Must be implemented in the child class.
|
94
|
+
|
95
|
+
Raises:
|
96
|
+
NotImplementedError: If the method is not implemented in the child class.
|
97
|
+
"""
|
98
|
+
raise NotImplementedError
|
99
|
+
|
100
|
+
@property
|
101
|
+
def previews_directory(self) -> str:
|
102
|
+
"""The directory where the preview images are stored.
|
103
|
+
|
104
|
+
Returns:
|
105
|
+
str: The directory where the preview images are stored.
|
106
|
+
"""
|
107
|
+
return os.path.join(self.map_directory, "previews")
|
108
|
+
|
109
|
+
@property
|
110
|
+
def info_layers_directory(self) -> str:
|
111
|
+
"""The directory where the info layers are stored.
|
112
|
+
|
113
|
+
Returns:
|
114
|
+
str: The directory where the info layers are stored.
|
115
|
+
"""
|
116
|
+
return os.path.join(self.map_directory, "info_layers")
|
117
|
+
|
118
|
+
@property
|
119
|
+
def scripts_directory(self) -> str:
|
120
|
+
"""The directory where the scripts are stored.
|
121
|
+
|
122
|
+
Returns:
|
123
|
+
str: The directory where the scripts are stored.
|
124
|
+
"""
|
125
|
+
return os.path.join(self.map_directory, "scripts")
|
126
|
+
|
127
|
+
@property
|
128
|
+
def satellite_directory(self) -> str:
|
129
|
+
"""The directory where the satellite images are stored.
|
130
|
+
|
131
|
+
Returns:
|
132
|
+
str: The directory where the satellite images are stored.
|
133
|
+
"""
|
134
|
+
return os.path.join(self.map_directory, "satellite")
|
135
|
+
|
136
|
+
@property
|
137
|
+
def generation_info_path(self) -> str:
|
138
|
+
"""The path to the generation info JSON file.
|
139
|
+
|
140
|
+
Returns:
|
141
|
+
str: The path to the generation info JSON file.
|
142
|
+
"""
|
143
|
+
return os.path.join(self.map_directory, "generation_info.json")
|
144
|
+
|
145
|
+
def info_sequence(self) -> dict[Any, Any]:
|
146
|
+
"""Returns the information sequence for the component. Must be implemented in the child
|
147
|
+
class. If the component does not have an information sequence, an empty dictionary must be
|
148
|
+
returned.
|
149
|
+
|
150
|
+
Returns:
|
151
|
+
dict[Any, Any]: The information sequence for the component.
|
152
|
+
"""
|
153
|
+
return {}
|
154
|
+
|
155
|
+
def commit_generation_info(self) -> None:
|
156
|
+
"""Commits the generation info to the generation info JSON file."""
|
157
|
+
self.update_generation_info(self.info_sequence())
|
158
|
+
|
159
|
+
def update_generation_info(self, data: dict[Any, Any]) -> None:
|
160
|
+
"""Updates the generation info with the provided data.
|
161
|
+
If the generation info file does not exist, it will be created.
|
162
|
+
|
163
|
+
Arguments:
|
164
|
+
data (dict[Any, Any]): The data to update the generation info with.
|
165
|
+
"""
|
166
|
+
if os.path.isfile(self.generation_info_path):
|
167
|
+
with open(self.generation_info_path, "r", encoding="utf-8") as file:
|
168
|
+
generation_info = json.load(file)
|
169
|
+
self.logger.debug("Loaded generation info from %s", self.generation_info_path)
|
170
|
+
else:
|
171
|
+
self.logger.debug(
|
172
|
+
"Generation info file does not exist, creating a new one in %s",
|
173
|
+
self.generation_info_path,
|
174
|
+
)
|
175
|
+
generation_info = {}
|
176
|
+
|
177
|
+
updated_generation_info = deepcopy(generation_info)
|
178
|
+
updated_generation_info[self.__class__.__name__] = data
|
179
|
+
|
180
|
+
self.logger.debug("Updated generation info, now contains %s fields", len(data))
|
181
|
+
|
182
|
+
with open(self.generation_info_path, "w", encoding="utf-8") as file:
|
183
|
+
json.dump(updated_generation_info, file, indent=4)
|
184
|
+
|
185
|
+
self.logger.debug("Saved updated generation info to %s", self.generation_info_path)
|
186
|
+
|
187
|
+
def get_bbox(
|
188
|
+
self,
|
189
|
+
coordinates: tuple[float, float] | None = None,
|
190
|
+
distance: int | None = None,
|
191
|
+
) -> tuple[float, float, float, float]:
|
192
|
+
"""Calculates the bounding box of the map from the coordinates and the height and
|
193
|
+
width of the map.
|
194
|
+
If coordinates and distance are not provided, the instance variables are used.
|
195
|
+
|
196
|
+
Arguments:
|
197
|
+
coordinates (tuple[float, float], optional): The latitude and longitude of the center
|
198
|
+
of the map. Defaults to None.
|
199
|
+
distance (int, optional): The distance from the center of the map to the edge of the
|
200
|
+
map in all directions. Defaults to None.
|
201
|
+
|
202
|
+
Returns:
|
203
|
+
tuple[float, float, float, float]: The bounding box of the map.
|
204
|
+
"""
|
205
|
+
coordinates = coordinates or self.coordinates
|
206
|
+
distance = distance or int(self.map_rotated_size / 2)
|
207
|
+
|
208
|
+
west, south, east, north = ox.utils_geo.bbox_from_point( # type: ignore
|
209
|
+
coordinates,
|
210
|
+
dist=distance,
|
211
|
+
)
|
212
|
+
|
213
|
+
bbox = north, south, east, west
|
214
|
+
self.logger.debug(
|
215
|
+
"Calculated bounding box for component: %s: %s, distance: %s",
|
216
|
+
self.__class__.__name__,
|
217
|
+
bbox,
|
218
|
+
distance,
|
219
|
+
)
|
220
|
+
return bbox
|
221
|
+
|
222
|
+
def save_bbox(self) -> None:
|
223
|
+
"""Saves the bounding box of the map to the component instance from the coordinates and the
|
224
|
+
height and width of the map.
|
225
|
+
"""
|
226
|
+
self.bbox = self.get_bbox()
|
227
|
+
self.logger.debug("Saved bounding box: %s", self.bbox)
|
228
|
+
|
229
|
+
@property
|
230
|
+
def new_bbox(self) -> tuple[float, float, float, float]:
|
231
|
+
"""This property is used for a new version of osmnx library, where the order of coordinates
|
232
|
+
has been changed to (left, bottom, right, top).
|
233
|
+
|
234
|
+
Returns:
|
235
|
+
tuple[float, float, float, float]: The bounding box of the map in the new order:
|
236
|
+
(left, bottom, right, top).
|
237
|
+
"""
|
238
|
+
# FutureWarning: The expected order of coordinates in `bbox`
|
239
|
+
# will change in the v2.0.0 release to `(left, bottom, right, top)`.
|
240
|
+
north, south, east, west = self.bbox
|
241
|
+
return west, south, east, north
|
242
|
+
|
243
|
+
def get_espg3857_bbox(
|
244
|
+
self, bbox: tuple[float, float, float, float] | None = None, add_margin: bool = False
|
245
|
+
) -> tuple[float, float, float, float]:
|
246
|
+
"""Converts the bounding box to EPSG:3857.
|
247
|
+
If the bounding box is not provided, the instance variable is used.
|
248
|
+
|
249
|
+
Arguments:
|
250
|
+
bbox (tuple[float, float, float, float], optional): The bounding box to convert.
|
251
|
+
add_margin (bool, optional): Whether to add a margin to the bounding box.
|
252
|
+
|
253
|
+
Returns:
|
254
|
+
tuple[float, float, float, float]: The bounding box in EPSG:3857.
|
255
|
+
"""
|
256
|
+
bbox = bbox or self.bbox
|
257
|
+
north, south, east, west = bbox
|
258
|
+
transformer = Transformer.from_crs("epsg:4326", "epsg:3857")
|
259
|
+
epsg3857_north, epsg3857_west = transformer.transform(north, west)
|
260
|
+
epsg3857_south, epsg3857_east = transformer.transform(south, east)
|
261
|
+
|
262
|
+
if add_margin:
|
263
|
+
MARGIN = 500 # pylint: disable=C0103
|
264
|
+
epsg3857_north = int(epsg3857_north - MARGIN)
|
265
|
+
epsg3857_south = int(epsg3857_south + MARGIN)
|
266
|
+
epsg3857_east = int(epsg3857_east - MARGIN)
|
267
|
+
epsg3857_west = int(epsg3857_west + MARGIN)
|
268
|
+
|
269
|
+
return epsg3857_north, epsg3857_south, epsg3857_east, epsg3857_west
|
270
|
+
|
271
|
+
def get_epsg3857_string(
|
272
|
+
self, bbox: tuple[float, float, float, float] | None = None, add_margin: bool = False
|
273
|
+
) -> str:
|
274
|
+
"""Converts the bounding box to EPSG:3857 string.
|
275
|
+
If the bounding box is not provided, the instance variable is used.
|
276
|
+
|
277
|
+
Arguments:
|
278
|
+
bbox (tuple[float, float, float, float], optional): The bounding box to convert.
|
279
|
+
add_margin (bool, optional): Whether to add a margin to the bounding box.
|
280
|
+
|
281
|
+
Returns:
|
282
|
+
str: The bounding box in EPSG:3857 string.
|
283
|
+
"""
|
284
|
+
north, south, east, west = self.get_espg3857_bbox(bbox, add_margin=add_margin)
|
285
|
+
return f"{north},{south},{east},{west} [EPSG:3857]"
|
286
|
+
|
287
|
+
def create_qgis_scripts(
|
288
|
+
self, qgis_layers: list[tuple[str, float, float, float, float]]
|
289
|
+
) -> None:
|
290
|
+
"""Creates QGIS scripts from the given layers.
|
291
|
+
Each layer is a tuple where the first element is a name of the layer and the rest are the
|
292
|
+
bounding box coordinates in EPSG:3857.
|
293
|
+
For filenames, the class name is used as a prefix.
|
294
|
+
|
295
|
+
Arguments:
|
296
|
+
qgis_layers (list[tuple[str, float, float, float, float]]): The list of layers to
|
297
|
+
create scripts for.
|
298
|
+
"""
|
299
|
+
class_name = self.__class__.__name__.lower()
|
300
|
+
save_scripts(qgis_layers, class_name, self.scripts_directory)
|
301
|
+
|
302
|
+
def get_polygon_center(self, polygon_points: list[tuple[int, int]]) -> tuple[int, int]:
|
303
|
+
"""Calculates the center of a polygon defined by a list of points.
|
304
|
+
|
305
|
+
Arguments:
|
306
|
+
polygon_points (list[tuple[int, int]]): The points of the polygon.
|
307
|
+
|
308
|
+
Returns:
|
309
|
+
tuple[int, int]: The center of the polygon.
|
310
|
+
"""
|
311
|
+
polygon = Polygon(polygon_points)
|
312
|
+
center = polygon.centroid
|
313
|
+
return int(center.x), int(center.y)
|
314
|
+
|
315
|
+
def absolute_to_relative(
|
316
|
+
self, point: tuple[int, int], center: tuple[int, int]
|
317
|
+
) -> tuple[int, int]:
|
318
|
+
"""Converts a pair of absolute coordinates to relative coordinates.
|
319
|
+
|
320
|
+
Arguments:
|
321
|
+
point (tuple[int, int]): The absolute coordinates.
|
322
|
+
center (tuple[int, int]): The center coordinates.
|
323
|
+
|
324
|
+
Returns:
|
325
|
+
tuple[int, int]: The relative coordinates.
|
326
|
+
"""
|
327
|
+
cx, cy = center
|
328
|
+
x, y = point
|
329
|
+
return x - cx, y - cy
|
330
|
+
|
331
|
+
def top_left_coordinates_to_center(self, top_left: tuple[int, int]) -> tuple[int, int]:
|
332
|
+
"""Converts a pair of coordinates from the top-left system to the center system.
|
333
|
+
In top-left system, the origin (0, 0) is in the top-left corner of the map, while in the
|
334
|
+
center system, the origin is in the center of the map.
|
335
|
+
|
336
|
+
Arguments:
|
337
|
+
top_left (tuple[int, int]): The coordinates in the top-left system.
|
338
|
+
|
339
|
+
Returns:
|
340
|
+
tuple[int, int]: The coordinates in the center system.
|
341
|
+
"""
|
342
|
+
x, y = top_left
|
343
|
+
cs_x = x - self.map_size // 2
|
344
|
+
cs_y = y - self.map_size // 2
|
345
|
+
|
346
|
+
return cs_x, cs_y
|
347
|
+
|
348
|
+
# pylint: disable=R0914
|
349
|
+
def fit_object_into_bounds(
|
350
|
+
self,
|
351
|
+
polygon_points: list[tuple[int, int]] | None = None,
|
352
|
+
linestring_points: list[tuple[int, int]] | None = None,
|
353
|
+
margin: int = 0,
|
354
|
+
angle: int = 0,
|
355
|
+
border: int = 0,
|
356
|
+
) -> list[tuple[int, int]]:
|
357
|
+
"""Fits a polygon into the bounds of the map.
|
358
|
+
|
359
|
+
Arguments:
|
360
|
+
polygon_points (list[tuple[int, int]]): The points of the polygon.
|
361
|
+
linestring_points (list[tuple[int, int]]): The points of the linestring.
|
362
|
+
margin (int, optional): The margin to add to the polygon. Defaults to 0.
|
363
|
+
angle (int, optional): The angle to rotate the polygon by. Defaults to 0.
|
364
|
+
border (int, optional): The border to add to the bounds. Defaults to 0.
|
365
|
+
|
366
|
+
Returns:
|
367
|
+
list[tuple[int, int]]: The points of the polygon fitted into the map bounds.
|
368
|
+
"""
|
369
|
+
if polygon_points is None and linestring_points is None:
|
370
|
+
raise ValueError("Either polygon or linestring points must be provided.")
|
371
|
+
|
372
|
+
min_x = min_y = 0 + border
|
373
|
+
max_x = max_y = self.map_size - border
|
374
|
+
|
375
|
+
object_type = Polygon if polygon_points else LineString
|
376
|
+
|
377
|
+
# polygon = Polygon(polygon_points)
|
378
|
+
osm_object = object_type(polygon_points or linestring_points)
|
379
|
+
|
380
|
+
if angle:
|
381
|
+
center_x = center_y = self.map_rotated_size // 2
|
382
|
+
self.logger.debug(
|
383
|
+
"Rotating the osm_object by %s degrees with center at %sx%s",
|
384
|
+
angle,
|
385
|
+
center_x,
|
386
|
+
center_y,
|
387
|
+
)
|
388
|
+
osm_object = rotate(osm_object, -angle, origin=(center_x, center_y))
|
389
|
+
offset = (self.map_size / 2) - (self.map_rotated_size / 2)
|
390
|
+
self.logger.debug("Translating the osm_object by %s", offset)
|
391
|
+
osm_object = translate(osm_object, xoff=offset, yoff=offset)
|
392
|
+
self.logger.debug("Rotated and translated the osm_object.")
|
393
|
+
|
394
|
+
if margin and object_type is Polygon:
|
395
|
+
osm_object = osm_object.buffer(margin, join_style="mitre")
|
396
|
+
if osm_object.is_empty:
|
397
|
+
raise ValueError("The osm_object is empty after adding the margin.")
|
398
|
+
|
399
|
+
# Create a bounding box for the map bounds
|
400
|
+
bounds = box(min_x, min_y, max_x, max_y)
|
401
|
+
|
402
|
+
# Intersect the osm_object with the bounds to fit it within the map
|
403
|
+
try:
|
404
|
+
fitted_osm_object = osm_object.intersection(bounds)
|
405
|
+
self.logger.debug("Fitted the osm_object into the bounds: %s", bounds)
|
406
|
+
except Exception as e:
|
407
|
+
raise ValueError( # pylint: disable=W0707
|
408
|
+
f"Could not fit the osm_object into the bounds: {e}"
|
409
|
+
)
|
410
|
+
|
411
|
+
if not isinstance(fitted_osm_object, object_type):
|
412
|
+
raise ValueError("The fitted osm_object is not valid (probably splitted into parts).")
|
413
|
+
|
414
|
+
# Return the fitted polygon points
|
415
|
+
if object_type is Polygon:
|
416
|
+
as_list = list(fitted_osm_object.exterior.coords)
|
417
|
+
elif object_type is LineString:
|
418
|
+
as_list = list(fitted_osm_object.coords)
|
419
|
+
else:
|
420
|
+
raise ValueError("The object type is not supported.")
|
421
|
+
|
422
|
+
if not as_list:
|
423
|
+
raise ValueError("The fitted osm_object has no points.")
|
424
|
+
return as_list
|
425
|
+
|
426
|
+
def get_infolayer_path(self, layer_name: str) -> str | None:
|
427
|
+
"""Returns the path to the info layer file.
|
428
|
+
|
429
|
+
Arguments:
|
430
|
+
layer_name (str): The name of the layer.
|
431
|
+
|
432
|
+
Returns:
|
433
|
+
str | None: The path to the info layer file or None if the layer does not exist.
|
434
|
+
"""
|
435
|
+
info_layer_path = os.path.join(self.info_layers_directory, f"{layer_name}.json")
|
436
|
+
if not os.path.isfile(info_layer_path):
|
437
|
+
self.logger.warning("Info layer %s does not exist", info_layer_path)
|
438
|
+
return None
|
439
|
+
return info_layer_path
|
440
|
+
|
441
|
+
# pylint: disable=R0913, R0917, R0914
|
442
|
+
def rotate_image(
|
443
|
+
self,
|
444
|
+
image_path: str,
|
445
|
+
angle: int,
|
446
|
+
output_height: int,
|
447
|
+
output_width: int,
|
448
|
+
output_path: str | None = None,
|
449
|
+
) -> None:
|
450
|
+
"""Rotates an image by a given angle around its center and cuts out the center to match
|
451
|
+
the output size.
|
452
|
+
|
453
|
+
Arguments:
|
454
|
+
image_path (str): The path to the image to rotate.
|
455
|
+
angle (int): The angle to rotate the image by.
|
456
|
+
output_height (int): The height of the output image.
|
457
|
+
output_width (int): The width of the output image.
|
458
|
+
"""
|
459
|
+
if not os.path.isfile(image_path):
|
460
|
+
self.logger.warning("Image %s does not exist", image_path)
|
461
|
+
return
|
462
|
+
|
463
|
+
# pylint: disable=no-member
|
464
|
+
image = cv2.imread(image_path, cv2.IMREAD_UNCHANGED)
|
465
|
+
if image is None:
|
466
|
+
self.logger.warning("Image %s could not be read", image_path)
|
467
|
+
return
|
468
|
+
|
469
|
+
self.logger.debug("Read image from %s with shape: %s", image_path, image.shape)
|
470
|
+
|
471
|
+
if not output_path:
|
472
|
+
output_path = image_path
|
473
|
+
|
474
|
+
height, width = image.shape[:2]
|
475
|
+
center = (width // 2, height // 2)
|
476
|
+
|
477
|
+
self.logger.debug(
|
478
|
+
"Rotating the image... Angle: %s, center: %s, height: %s, width: %s",
|
479
|
+
angle,
|
480
|
+
center,
|
481
|
+
height,
|
482
|
+
width,
|
483
|
+
)
|
484
|
+
|
485
|
+
rotation_matrix = cv2.getRotationMatrix2D(center, angle, 1.0)
|
486
|
+
rotated = cv2.warpAffine(image, rotation_matrix, (width, height))
|
487
|
+
|
488
|
+
start_x = center[0] - output_width // 2
|
489
|
+
start_y = center[1] - output_height // 2
|
490
|
+
end_x = start_x + output_width
|
491
|
+
end_y = start_y + output_height
|
492
|
+
|
493
|
+
self.logger.debug(
|
494
|
+
"Cropping the rotated image: start_x: %s, start_y: %s, end_x: %s, end_y: %s",
|
495
|
+
start_x,
|
496
|
+
start_y,
|
497
|
+
end_x,
|
498
|
+
end_y,
|
499
|
+
)
|
500
|
+
|
501
|
+
cropped = rotated[start_y:end_y, start_x:end_x]
|
502
|
+
|
503
|
+
self.logger.debug("Shape of the cropped image: %s", cropped.shape)
|
504
|
+
|
505
|
+
cv2.imwrite(output_path, cropped)
|
506
|
+
|
507
|
+
@staticmethod
|
508
|
+
def interpolate_points(
|
509
|
+
polyline: list[tuple[int, int]], num_points: int = 4
|
510
|
+
) -> list[tuple[int, int]]:
|
511
|
+
"""Receives a list of tuples, which represents a polyline. Add additional points
|
512
|
+
between the existing points to make the polyline smoother.
|
513
|
+
|
514
|
+
Arguments:
|
515
|
+
polyline (list[tuple[int, int]]): The list of points to interpolate.
|
516
|
+
num_points (int): The number of additional points to add between each pair of points.
|
517
|
+
|
518
|
+
Returns:
|
519
|
+
list[tuple[int, int]]: The list of points with additional points.
|
520
|
+
"""
|
521
|
+
if not polyline or num_points < 1:
|
522
|
+
return polyline
|
523
|
+
|
524
|
+
interpolated_polyline = []
|
525
|
+
for i in range(len(polyline) - 1):
|
526
|
+
p1 = polyline[i]
|
527
|
+
p2 = polyline[i + 1]
|
528
|
+
interpolated_polyline.append(p1)
|
529
|
+
for j in range(1, num_points + 1):
|
530
|
+
new_point = (
|
531
|
+
p1[0] + (p2[0] - p1[0]) * j / (num_points + 1),
|
532
|
+
p1[1] + (p2[1] - p1[1]) * j / (num_points + 1),
|
533
|
+
)
|
534
|
+
interpolated_polyline.append((int(new_point[0]), int(new_point[1])))
|
535
|
+
interpolated_polyline.append(polyline[-1])
|
536
|
+
|
537
|
+
return interpolated_polyline
|
538
|
+
|
539
|
+
def get_z_scaling_factor(self) -> float:
|
540
|
+
"""Calculates the scaling factor for the Z axis based on the map settings.
|
541
|
+
|
542
|
+
Returns:
|
543
|
+
float -- The scaling factor for the Z axis.
|
544
|
+
"""
|
545
|
+
|
546
|
+
scaling_factor = 1 / self.map.dem_settings.multiplier
|
547
|
+
|
548
|
+
if self.map.shared_settings.height_scale_multiplier:
|
549
|
+
scaling_factor *= self.map.shared_settings.height_scale_multiplier
|
550
|
+
if self.map.shared_settings.mesh_z_scaling_factor:
|
551
|
+
scaling_factor *= 1 / self.map.shared_settings.mesh_z_scaling_factor
|
552
|
+
|
553
|
+
return scaling_factor
|
@@ -0,0 +1,109 @@
|
|
1
|
+
"""This module contains the Config class for map settings and configuration."""
|
2
|
+
|
3
|
+
from __future__ import annotations
|
4
|
+
|
5
|
+
import os
|
6
|
+
from xml.etree import ElementTree as ET
|
7
|
+
|
8
|
+
from maps4fs.generator.component import Component
|
9
|
+
|
10
|
+
|
11
|
+
# pylint: disable=R0903
|
12
|
+
class Config(Component):
|
13
|
+
"""Component for map settings and configuration.
|
14
|
+
|
15
|
+
Arguments:
|
16
|
+
game (Game): The game instance for which the map is generated.
|
17
|
+
coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
|
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.
|
21
|
+
map_directory (str): The directory where the map files are stored.
|
22
|
+
logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
|
23
|
+
info, warning. If not provided, default logging will be used.
|
24
|
+
"""
|
25
|
+
|
26
|
+
def preprocess(self) -> None:
|
27
|
+
"""Gets the path to the map XML file and saves it to the instance variable."""
|
28
|
+
self._map_xml_path = self.game.map_xml_path(self.map_directory)
|
29
|
+
self.logger.debug("Map XML path: %s.", self._map_xml_path)
|
30
|
+
|
31
|
+
def process(self) -> None:
|
32
|
+
"""Sets the map size in the map.xml file."""
|
33
|
+
self._set_map_size()
|
34
|
+
|
35
|
+
def _set_map_size(self) -> None:
|
36
|
+
"""Edits map.xml file to set correct map size."""
|
37
|
+
if not os.path.isfile(self._map_xml_path):
|
38
|
+
self.logger.warning("Map XML file not found: %s.", self._map_xml_path)
|
39
|
+
return
|
40
|
+
tree = ET.parse(self._map_xml_path)
|
41
|
+
self.logger.debug("Map XML file loaded from: %s.", self._map_xml_path)
|
42
|
+
root = tree.getroot()
|
43
|
+
for map_elem in root.iter("map"):
|
44
|
+
map_elem.set("width", str(self.map_size))
|
45
|
+
map_elem.set("height", str(self.map_size))
|
46
|
+
self.logger.debug(
|
47
|
+
"Map size set to %sx%s in Map XML file.",
|
48
|
+
self.map_size,
|
49
|
+
self.map_size,
|
50
|
+
)
|
51
|
+
tree.write(self._map_xml_path)
|
52
|
+
self.logger.debug("Map XML file saved to: %s.", self._map_xml_path)
|
53
|
+
|
54
|
+
def previews(self) -> list[str]:
|
55
|
+
"""Returns a list of paths to the preview images (empty list).
|
56
|
+
The component does not generate any preview images so it returns an empty list.
|
57
|
+
|
58
|
+
Returns:
|
59
|
+
list[str]: An empty list.
|
60
|
+
"""
|
61
|
+
return []
|
62
|
+
|
63
|
+
def info_sequence(self) -> dict[str, dict[str, str | float | int]]:
|
64
|
+
"""Returns information about the component.
|
65
|
+
Overview section is needed to create the overview file (in-game map).
|
66
|
+
|
67
|
+
Returns:
|
68
|
+
dict[str, dict[str, str | float | int]]: Information about the component.
|
69
|
+
"""
|
70
|
+
# The overview file is exactly 2X bigger than the map size, does not matter
|
71
|
+
# if the map is 2048x2048 or 4096x4096, the overview will be 4096x4096
|
72
|
+
# and the map will be in the center of the overview.
|
73
|
+
# That's why the distance is set to the map height not as a half of it.
|
74
|
+
bbox = self.get_bbox(distance=self.map_size)
|
75
|
+
south, west, north, east = bbox
|
76
|
+
epsg3857_string = self.get_epsg3857_string(bbox=bbox)
|
77
|
+
epsg3857_string_with_margin = self.get_epsg3857_string(bbox=bbox, add_margin=True)
|
78
|
+
|
79
|
+
self.qgis_sequence()
|
80
|
+
|
81
|
+
overview_data = {
|
82
|
+
"epsg3857_string": epsg3857_string,
|
83
|
+
"epsg3857_string_with_margin": epsg3857_string_with_margin,
|
84
|
+
"south": south,
|
85
|
+
"west": west,
|
86
|
+
"north": north,
|
87
|
+
"east": east,
|
88
|
+
"height": self.map_size * 2,
|
89
|
+
"width": self.map_size * 2,
|
90
|
+
}
|
91
|
+
|
92
|
+
data = {
|
93
|
+
"Overview": overview_data,
|
94
|
+
}
|
95
|
+
|
96
|
+
return data # type: ignore
|
97
|
+
|
98
|
+
def qgis_sequence(self) -> None:
|
99
|
+
"""Generates QGIS scripts for creating bounding box layers and rasterizing them."""
|
100
|
+
bbox = self.get_bbox(distance=self.map_size)
|
101
|
+
espg3857_bbox = self.get_espg3857_bbox(bbox=bbox)
|
102
|
+
espg3857_bbox_with_margin = self.get_espg3857_bbox(bbox=bbox, add_margin=True)
|
103
|
+
|
104
|
+
qgis_layers = [("Overview_bbox", *espg3857_bbox)]
|
105
|
+
qgis_layers_with_margin = [("Overview_bbox_with_margin", *espg3857_bbox_with_margin)]
|
106
|
+
|
107
|
+
layers = qgis_layers + qgis_layers_with_margin
|
108
|
+
|
109
|
+
self.create_qgis_scripts(layers)
|