maps4fs 1.8.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.
- 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)
|