maps4fs 1.5.0__py3-none-any.whl → 1.6.91__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/grle.py CHANGED
@@ -10,13 +10,26 @@ import numpy as np
10
10
  from shapely.geometry import Polygon # type: ignore
11
11
 
12
12
  from maps4fs.generator.component import Component
13
- from maps4fs.generator.texture import Texture
13
+ from maps4fs.generator.texture import PREVIEW_MAXIMUM_SIZE, Texture
14
14
 
15
- ISLAND_SIZE_MIN = 10
16
- ISLAND_SIZE_MAX = 200
17
15
  ISLAND_DISTORTION = 0.3
18
- ISLAND_VERTEX_COUNT = 30
19
- ISLAND_ROUNDING_RADIUS = 15
16
+
17
+
18
+ def plant_to_pixel_value(plant_name: str) -> int | None:
19
+ """Returns the pixel value representation of the plant.
20
+ If not found, returns None.
21
+
22
+ Arguments:
23
+ plant_name (str): name of the plant
24
+
25
+ Returns:
26
+ int | None: pixel value of the plant or None if not found.
27
+ """
28
+ plants = {
29
+ "smallDenseMix": 33,
30
+ "meadow": 131,
31
+ }
32
+ return plants.get(plant_name)
20
33
 
21
34
 
22
35
  # pylint: disable=W0223
@@ -39,6 +52,7 @@ class GRLE(Component):
39
52
  def preprocess(self) -> None:
40
53
  """Gets the path to the map I3D file from the game instance and saves it to the instance
41
54
  attribute. If the game does not support I3D files, the attribute is set to None."""
55
+ self.preview_paths: dict[str, str] = {}
42
56
 
43
57
  try:
44
58
  grle_schema_path = self.game.grle_schema
@@ -89,6 +103,7 @@ class GRLE(Component):
89
103
  else:
90
104
  self.logger.warning("Adding plants it's not supported for the %s.", self.game.code)
91
105
 
106
+ # pylint: disable=no-member
92
107
  def previews(self) -> list[str]:
93
108
  """Returns a list of paths to the preview images (empty list).
94
109
  The component does not generate any preview images so it returns an empty list.
@@ -96,7 +111,57 @@ class GRLE(Component):
96
111
  Returns:
97
112
  list[str]: An empty list.
98
113
  """
99
- return []
114
+ preview_paths = []
115
+ for preview_name, preview_path in self.preview_paths.items():
116
+ save_path = os.path.join(self.previews_directory, f"{preview_name}.png")
117
+ # Resize the preview image to the maximum size allowed for previews.
118
+ image = cv2.imread(preview_path, cv2.IMREAD_GRAYSCALE)
119
+ if image.shape[0] > PREVIEW_MAXIMUM_SIZE or image.shape[1] > PREVIEW_MAXIMUM_SIZE:
120
+ image = cv2.resize(image, (PREVIEW_MAXIMUM_SIZE, PREVIEW_MAXIMUM_SIZE))
121
+ image_normalized = np.empty_like(image)
122
+ cv2.normalize(image, image_normalized, 0, 255, cv2.NORM_MINMAX)
123
+ image_colored = cv2.applyColorMap(image_normalized, cv2.COLORMAP_JET)
124
+ cv2.imwrite(save_path, image_colored)
125
+ preview_paths.append(save_path)
126
+
127
+ with_fields_save_path = os.path.join(
128
+ self.previews_directory, f"{preview_name}_with_fields.png"
129
+ )
130
+ image_with_fields = self.overlay_fields(image_colored)
131
+ if image_with_fields is None:
132
+ continue
133
+ cv2.imwrite(with_fields_save_path, image_with_fields) # pylint: disable=no-member
134
+ preview_paths.append(with_fields_save_path)
135
+
136
+ return preview_paths
137
+
138
+ def overlay_fields(self, farmlands_np: np.ndarray) -> np.ndarray | None:
139
+ """Overlay fields on the farmlands preview image.
140
+
141
+ Arguments:
142
+ farmlands_np (np.ndarray): The farmlands preview image.
143
+
144
+ Returns:
145
+ np.ndarray | None: The farmlands preview image with fields overlayed on top of it.
146
+ """
147
+ texture_component: Texture | None = self.map.get_component("Texture") # type: ignore
148
+ if not texture_component:
149
+ self.logger.warning("Texture component not found in the map.")
150
+ return None
151
+
152
+ fields_layer = texture_component.get_layer_by_usage("field")
153
+ fields_layer_path = fields_layer.get_preview_or_path( # type: ignore
154
+ self.game.weights_dir_path(self.map_directory)
155
+ )
156
+ if not fields_layer_path or not os.path.isfile(fields_layer_path):
157
+ self.logger.warning("Fields layer not found in the texture component.")
158
+ return None
159
+ fields_np = cv2.imread(fields_layer_path)
160
+ # Resize fields_np to the same size as farmlands_np.
161
+ fields_np = cv2.resize(fields_np, (farmlands_np.shape[1], farmlands_np.shape[0]))
162
+
163
+ # use fields_np as base layer and overlay farmlands_np on top of it with 50% alpha blending.
164
+ return cv2.addWeighted(fields_np, 0.5, farmlands_np, 0.5, 0)
100
165
 
101
166
  # pylint: disable=R0801, R0914
102
167
  def _add_farmlands(self) -> None:
@@ -114,12 +179,12 @@ class GRLE(Component):
114
179
  self.logger.warning("Fields data not found in textures info layer.")
115
180
  return
116
181
 
117
- self.logger.info("Found %s fields in textures info layer.", len(fields))
182
+ self.logger.debug("Found %s fields in textures info layer.", len(fields))
118
183
 
119
184
  farmyards: list[list[tuple[int, int]]] | None = textures_info_layer.get("farmyards")
120
185
  if farmyards and self.map.grle_settings.add_farmyards:
121
186
  fields.extend(farmyards)
122
- self.logger.info("Found %s farmyards in textures info layer.", len(farmyards))
187
+ self.logger.debug("Found %s farmyards in textures info layer.", len(farmyards))
123
188
 
124
189
  info_layer_farmlands_path = os.path.join(
125
190
  self.game.weights_dir_path(self.map_directory), "infoLayer_farmlands.png"
@@ -155,7 +220,7 @@ class GRLE(Component):
155
220
  angle=self.rotation,
156
221
  )
157
222
  except ValueError as e:
158
- self.logger.warning(
223
+ self.logger.debug(
159
224
  "Farmland %s could not be fitted into the map bounds with error: %s",
160
225
  farmland_id,
161
226
  e,
@@ -180,7 +245,7 @@ class GRLE(Component):
180
245
  try:
181
246
  cv2.fillPoly(image, [field_np], farmland_id) # type: ignore
182
247
  except Exception as e: # pylint: disable=W0718
183
- self.logger.warning(
248
+ self.logger.debug(
184
249
  "Farmland %s could not be added to the InfoLayer PNG file with error: %s",
185
250
  farmland_id,
186
251
  e,
@@ -204,6 +269,8 @@ class GRLE(Component):
204
269
  "Farmlands added to the InfoLayer PNG file: %s.", info_layer_farmlands_path
205
270
  )
206
271
 
272
+ self.preview_paths["farmlands"] = info_layer_farmlands_path # type: ignore
273
+
207
274
  # pylint: disable=R0915
208
275
  def _add_plants(self) -> None:
209
276
  """Adds plants to the InfoLayer PNG file."""
@@ -270,10 +337,13 @@ class GRLE(Component):
270
337
  grass_image[forest_image != 0] = 255
271
338
 
272
339
  # B and G channels remain the same (zeros), while we change the R channel.
273
- possible_R_values = [33, 65, 97, 129, 161, 193, 225] # pylint: disable=C0103
340
+ possible_R_values = [65, 97, 129, 161, 193, 225] # pylint: disable=C0103
274
341
 
275
- # 1st approach: Change the non zero values in the base image to 33 (for debug).
276
- # And use the base image as R channel in the density map.
342
+ base_layer_pixel_value = plant_to_pixel_value(
343
+ self.map.grle_settings.base_grass # type:ignore
344
+ )
345
+ if not base_layer_pixel_value:
346
+ base_layer_pixel_value = 131
277
347
 
278
348
  # pylint: disable=no-member
279
349
  def create_island_of_plants(image: np.ndarray, count: int) -> np.ndarray:
@@ -290,23 +360,20 @@ class GRLE(Component):
290
360
  # Randomly choose the value for the island.
291
361
  plant_value = choice(possible_R_values)
292
362
  # Randomly choose the size of the island.
293
- island_size = randint(ISLAND_SIZE_MIN, ISLAND_SIZE_MAX)
363
+ island_size = randint(
364
+ self.map.grle_settings.plants_island_minimum_size, # type:ignore
365
+ self.map.grle_settings.plants_island_maximum_size, # type:ignore
366
+ )
294
367
  # Randomly choose the position of the island.
295
- # x = np.random.randint(0, image.shape[1] - island_size)
296
- # y = np.random.randint(0, image.shape[0] - island_size)
297
368
  x = randint(0, image.shape[1] - island_size)
298
369
  y = randint(0, image.shape[0] - island_size)
299
370
 
300
- # Randomly choose the shape of the island.
301
- # shapes = ["circle", "ellipse", "polygon"]
302
- # shape = choice(shapes)
303
-
304
371
  try:
305
372
  polygon_points = get_rounded_polygon(
306
- num_vertices=ISLAND_VERTEX_COUNT,
373
+ num_vertices=self.map.grle_settings.plants_island_vertex_count,
307
374
  center=(x + island_size // 2, y + island_size // 2),
308
375
  radius=island_size // 2,
309
- rounding_radius=ISLAND_ROUNDING_RADIUS,
376
+ rounding_radius=self.map.grle_settings.plants_island_rounding_radius,
310
377
  )
311
378
  if not polygon_points:
312
379
  continue
@@ -355,16 +422,16 @@ class GRLE(Component):
355
422
  grass_image_copy = grass_image.copy()
356
423
  if forest_image is not None:
357
424
  # Add the forest layer to the base image, to merge the masks.
358
- grass_image_copy[forest_image != 0] = 33
359
- # Set all the non-zero values to 33.
360
- grass_image_copy[grass_image != 0] = 33
425
+ grass_image_copy[forest_image != 0] = base_layer_pixel_value
426
+
427
+ grass_image_copy[grass_image != 0] = base_layer_pixel_value
361
428
 
362
429
  # Add islands of plants to the base image.
363
- island_count = self.map_size
430
+ island_count = int(self.map_size * self.map.grle_settings.plants_island_percent // 100)
364
431
  self.logger.debug("Adding %s islands of plants to the base image.", island_count)
365
432
  if self.map.grle_settings.random_plants:
366
433
  grass_image_copy = create_island_of_plants(grass_image_copy, island_count)
367
- self.logger.debug("Islands of plants added to the base image.")
434
+ self.logger.info("Added %s islands of plants to the base image.", island_count)
368
435
 
369
436
  # Sligtly reduce the size of the grass_image, that we'll use as mask.
370
437
  kernel = np.ones((3, 3), np.uint8)
@@ -380,7 +447,6 @@ class GRLE(Component):
380
447
  grass_image_copy[:, 0] = 0 # Left side
381
448
  grass_image_copy[:, -1] = 0 # Right side
382
449
 
383
- # Value of 33 represents the base grass plant.
384
450
  # After painting it with base grass, we'll create multiple islands of different plants.
385
451
  # On the final step, we'll remove all the values which in pixels
386
452
  # where zerons in the original base image (so we don't paint grass where it should not be).
maps4fs/generator/i3d.py CHANGED
@@ -14,14 +14,10 @@ import numpy as np
14
14
  from maps4fs.generator.component import Component
15
15
  from maps4fs.generator.texture import Texture
16
16
 
17
- DEFAULT_HEIGHT_SCALE = 2000
18
17
  DISPLACEMENT_LAYER_SIZE_FOR_BIG_MAPS = 32768
19
- DEFAULT_MAX_LOD_DISTANCE = 10000
20
- DEFAULT_MAX_LOD_OCCLUDER_DISTANCE = 10000
21
18
  NODE_ID_STARTING_VALUE = 2000
22
19
  SPLINES_NODE_ID_STARTING_VALUE = 5000
23
20
  TREE_NODE_ID_STARTING_VALUE = 10000
24
- DEFAULT_FOREST_DENSITY = 10
25
21
 
26
22
 
27
23
  # pylint: disable=R0903
@@ -82,19 +78,19 @@ class I3d(Component):
82
78
  root = tree.getroot()
83
79
  for map_elem in root.iter("Scene"):
84
80
  for terrain_elem in map_elem.iter("TerrainTransformGroup"):
85
- if self.map.dem_settings.auto_process:
86
- terrain_elem.set("heightScale", str(DEFAULT_HEIGHT_SCALE))
87
- self.logger.debug(
88
- "heightScale attribute set to %s in TerrainTransformGroup element.",
89
- DEFAULT_HEIGHT_SCALE,
90
- )
91
- else:
92
- self.logger.debug(
93
- "Auto process is disabled, skipping the heightScale attribute update."
94
- )
81
+ if self.map.shared_settings.change_height_scale:
82
+ suggested_height_scale = self.map.shared_settings.height_scale_value
83
+ if suggested_height_scale is not None and suggested_height_scale > 255:
84
+ new_height_scale = int(
85
+ self.map.shared_settings.height_scale_value # type: ignore
86
+ )
87
+ terrain_elem.set("heightScale", str(new_height_scale))
88
+ self.logger.info(
89
+ "heightScale attribute set to %s in TerrainTransformGroup element.",
90
+ new_height_scale,
91
+ )
95
92
 
96
93
  self.logger.debug("TerrainTransformGroup element updated in I3D file.")
97
-
98
94
  sun_elem = map_elem.find(".//Light[@name='sun']")
99
95
 
100
96
  if sun_elem is not None:
@@ -156,7 +152,7 @@ class I3d(Component):
156
152
  self.logger.warning("Roads polylines data not found in textures info layer.")
157
153
  return
158
154
 
159
- self.logger.info("Found %s roads polylines in textures info layer.", len(roads_polylines))
155
+ self.logger.debug("Found %s roads polylines in textures info layer.", len(roads_polylines))
160
156
  self.logger.debug("Starging to add roads polylines to the I3D file.")
161
157
 
162
158
  root = tree.getroot()
@@ -198,7 +194,7 @@ class I3d(Component):
198
194
  linestring_points=road, angle=self.rotation
199
195
  )
200
196
  except ValueError as e:
201
- self.logger.warning(
197
+ self.logger.debug(
202
198
  "Road %s could not be fitted into the map bounds with error: %s",
203
199
  road_id,
204
200
  e,
@@ -249,7 +245,7 @@ class I3d(Component):
249
245
  y = max(0, min(y, dem_y_size - 1))
250
246
 
251
247
  z = not_resized_dem[y, x]
252
- z /= 32 # Yes, it's a magic number here.
248
+ z *= self.get_z_scaling_factor() # type: ignore
253
249
 
254
250
  cv_node = ET.Element("cv")
255
251
  cv_node.set("c", f"{cx}, {z}, {cy}")
@@ -301,7 +297,7 @@ class I3d(Component):
301
297
  self.logger.warning("Fields data not found in textures info layer.")
302
298
  return
303
299
 
304
- self.logger.info("Found %s fields in textures info layer.", len(fields))
300
+ self.logger.debug("Found %s fields in textures info layer.", len(fields))
305
301
  self.logger.debug("Starging to add fields to the I3D file.")
306
302
 
307
303
  root = tree.getroot()
@@ -323,7 +319,7 @@ class I3d(Component):
323
319
  polygon_points=field, angle=self.rotation
324
320
  )
325
321
  except ValueError as e:
326
- self.logger.warning(
322
+ self.logger.debug(
327
323
  "Field %s could not be fitted into the map bounds with error: %s",
328
324
  field_id,
329
325
  e,
@@ -337,7 +333,7 @@ class I3d(Component):
337
333
  try:
338
334
  cx, cy = self.get_polygon_center(field_ccs)
339
335
  except Exception as e: # pylint: disable=W0718
340
- self.logger.warning(
336
+ self.logger.debug(
341
337
  "Field %s could not be fitted into the map bounds.", field_id
342
338
  )
343
339
  self.logger.debug("Error: %s", e)
maps4fs/generator/map.py CHANGED
@@ -7,145 +7,22 @@ import os
7
7
  import shutil
8
8
  from typing import Any, Generator
9
9
 
10
- from pydantic import BaseModel
11
-
12
10
  from maps4fs.generator.component import Component
11
+ from maps4fs.generator.dtm.dtm import DTMProvider, DTMProviderSettings
13
12
  from maps4fs.generator.game import Game
13
+ from maps4fs.generator.settings import (
14
+ BackgroundSettings,
15
+ DEMSettings,
16
+ GRLESettings,
17
+ I3DSettings,
18
+ SatelliteSettings,
19
+ SharedSettings,
20
+ SplineSettings,
21
+ TextureSettings,
22
+ )
14
23
  from maps4fs.logger import Logger
15
24
 
16
25
 
17
- class SettingsModel(BaseModel):
18
- """Base class for settings models. It provides methods to convert settings to and from JSON."""
19
-
20
- @classmethod
21
- def all_settings_to_json(cls) -> dict[str, dict[str, Any]]:
22
- """Get all settings of the current class and its subclasses as a dictionary.
23
-
24
- Returns:
25
- dict[str, dict[str, Any]]: Dictionary with settings of the current class and its
26
- subclasses.
27
- """
28
- all_settings = {}
29
- for subclass in cls.__subclasses__():
30
- all_settings[subclass.__name__] = subclass().model_dump()
31
-
32
- return all_settings
33
-
34
- @classmethod
35
- def all_settings_from_json(cls, data: dict) -> dict[str, SettingsModel]:
36
- """Create settings instances from JSON data.
37
-
38
- Arguments:
39
- data (dict): JSON data.
40
-
41
- Returns:
42
- dict[str, Type[SettingsModel]]: Dictionary with settings instances.
43
- """
44
- settings = {}
45
- for subclass in cls.__subclasses__():
46
- settings[subclass.__name__] = subclass(**data[subclass.__name__])
47
-
48
- return settings
49
-
50
- @classmethod
51
- def all_settings(cls) -> list[SettingsModel]:
52
- """Get all settings of the current class and its subclasses.
53
-
54
- Returns:
55
- list[SettingsModel]: List with settings of the current class and its subclasses.
56
- """
57
- settings = []
58
- for subclass in cls.__subclasses__():
59
- settings.append(subclass())
60
-
61
- return settings
62
-
63
-
64
- class DEMSettings(SettingsModel):
65
- """Represents the advanced settings for DEM component.
66
-
67
- Attributes:
68
- auto_process (bool): use the auto preset to change the multiplier.
69
- multiplier (int): multiplier for the heightmap, every pixel will be multiplied by this
70
- value.
71
- blur_radius (int): radius of the blur filter.
72
- plateau (int): plateau height, will be added to each pixel.
73
- water_depth (int): water depth, will be subtracted from each pixel where the water
74
- is present.
75
- """
76
-
77
- auto_process: bool = True
78
- multiplier: int = 1
79
- blur_radius: int = 35
80
- plateau: int = 0
81
- water_depth: int = 0
82
-
83
-
84
- class BackgroundSettings(SettingsModel):
85
- """Represents the advanced settings for background component.
86
-
87
- Attributes:
88
- generate_background (bool): generate obj files for the background terrain.
89
- generate_water (bool): generate obj files for the water.
90
- resize_factor (int): resize factor for the background terrain and water.
91
- It will be used as 1 / resize_factor of the original size.
92
- """
93
-
94
- generate_background: bool = True
95
- generate_water: bool = True
96
- resize_factor: int = 8
97
-
98
-
99
- class GRLESettings(SettingsModel):
100
- """Represents the advanced settings for GRLE component.
101
-
102
- Attributes:
103
- farmland_margin (int): margin around the farmland.
104
- random_plants (bool): generate random plants on the map or use the default one.
105
- add_farmyards (bool): If True, regions of frarmyards will be added to the map
106
- without corresponding fields.
107
- """
108
-
109
- farmland_margin: int = 0
110
- random_plants: bool = True
111
- add_farmyards: bool = False
112
-
113
-
114
- class I3DSettings(SettingsModel):
115
- """Represents the advanced settings for I3D component.
116
-
117
- Attributes:
118
- forest_density (int): density of the forest (distance between trees).
119
- """
120
-
121
- forest_density: int = 10
122
-
123
-
124
- class TextureSettings(SettingsModel):
125
- """Represents the advanced settings for texture component.
126
-
127
- Attributes:
128
- dissolve (bool): dissolve the texture into several images.
129
- fields_padding (int): padding around the fields.
130
- skip_drains (bool): skip drains generation.
131
- """
132
-
133
- dissolve: bool = False
134
- fields_padding: int = 0
135
- skip_drains: bool = False
136
-
137
-
138
- class SplineSettings(SettingsModel):
139
- """Represents the advanced settings for spline component.
140
-
141
- Attributes:
142
- spline_density (int): the number of extra points that will be added between each two
143
- existing points.
144
- """
145
-
146
- spline_density: int = 2
147
-
148
-
149
26
  # pylint: disable=R0913, R0902, R0914
150
27
  class Map:
151
28
  """Class used to generate map using all components.
@@ -161,6 +38,8 @@ class Map:
161
38
  def __init__( # pylint: disable=R0917, R0915
162
39
  self,
163
40
  game: Game,
41
+ dtm_provider: DTMProvider,
42
+ dtm_provider_settings: DTMProviderSettings,
164
43
  coordinates: tuple[float, float],
165
44
  size: int,
166
45
  rotation: int,
@@ -173,6 +52,7 @@ class Map:
173
52
  i3d_settings: I3DSettings = I3DSettings(),
174
53
  texture_settings: TextureSettings = TextureSettings(),
175
54
  spline_settings: SplineSettings = SplineSettings(),
55
+ satellite_settings: SatelliteSettings = SatelliteSettings(),
176
56
  **kwargs,
177
57
  ):
178
58
  if not logger:
@@ -189,6 +69,8 @@ class Map:
189
69
  self.rotated_size = int(size * rotation_multiplier)
190
70
 
191
71
  self.game = game
72
+ self.dtm_provider = dtm_provider
73
+ self.dtm_provider_settings = dtm_provider_settings
192
74
  self.components: list[Component] = []
193
75
  self.coordinates = coordinates
194
76
  self.map_directory = map_directory
@@ -207,6 +89,17 @@ class Map:
207
89
 
208
90
  self.dem_settings = dem_settings
209
91
  self.logger.info("DEM settings: %s", dem_settings)
92
+ if self.dem_settings.water_depth > 0:
93
+ # Make sure that the plateau value is >= water_depth
94
+ self.dem_settings.plateau = max(
95
+ self.dem_settings.plateau, self.dem_settings.water_depth
96
+ )
97
+ self.logger.info(
98
+ "Plateau value was set to %s to be >= water_depth value %s",
99
+ self.dem_settings.plateau,
100
+ self.dem_settings.water_depth,
101
+ )
102
+
210
103
  self.background_settings = background_settings
211
104
  self.logger.info("Background settings: %s", background_settings)
212
105
  self.grle_settings = grle_settings
@@ -217,6 +110,7 @@ class Map:
217
110
  self.logger.info("Texture settings: %s", texture_settings)
218
111
  self.spline_settings = spline_settings
219
112
  self.logger.info("Spline settings: %s", spline_settings)
113
+ self.satellite_settings = satellite_settings
220
114
 
221
115
  os.makedirs(self.map_directory, exist_ok=True)
222
116
  self.logger.debug("Map directory created: %s", self.map_directory)
@@ -228,6 +122,7 @@ class Map:
228
122
  i3d_settings,
229
123
  texture_settings,
230
124
  spline_settings,
125
+ satellite_settings,
231
126
  ]
232
127
 
233
128
  settings_json = {}
@@ -240,6 +135,8 @@ class Map:
240
135
  with open(save_path, "w", encoding="utf-8") as file:
241
136
  json.dump(settings_json, file, indent=4)
242
137
 
138
+ self.shared_settings = SharedSettings()
139
+
243
140
  self.texture_custom_schema = kwargs.get("texture_custom_schema", None)
244
141
  if self.texture_custom_schema:
245
142
  save_path = os.path.join(self.map_directory, "texture_custom_schema.json")
@@ -249,11 +146,17 @@ class Map:
249
146
 
250
147
  self.tree_custom_schema = kwargs.get("tree_custom_schema", None)
251
148
  if self.tree_custom_schema:
149
+ self.logger.info("Custom tree schema contains %s trees", len(self.tree_custom_schema))
252
150
  save_path = os.path.join(self.map_directory, "tree_custom_schema.json")
253
151
  with open(save_path, "w", encoding="utf-8") as file:
254
152
  json.dump(self.tree_custom_schema, file, indent=4)
255
153
  self.logger.debug("Tree custom schema saved to %s", save_path)
256
154
 
155
+ self.custom_background_path = kwargs.get("custom_background_path", None)
156
+ if self.custom_background_path:
157
+ save_path = os.path.join(self.map_directory, "custom_background.png")
158
+ shutil.copyfile(self.custom_background_path, save_path)
159
+
257
160
  try:
258
161
  shutil.unpack_archive(game.template_path, self.map_directory)
259
162
  self.logger.debug("Map template unpacked to %s", self.map_directory)
@@ -362,7 +265,7 @@ class Map:
362
265
  str: Path to the archive.
363
266
  """
364
267
  archive_path = shutil.make_archive(archive_path, "zip", self.map_directory)
365
- self.logger.info("Map packed to %s.zip", archive_path)
268
+ self.logger.debug("Map packed to %s.zip", archive_path)
366
269
  if remove_source:
367
270
  try:
368
271
  shutil.rmtree(self.map_directory)
@@ -0,0 +1,92 @@
1
+ """This module contains the Satellite class for the maps4fs package to download satellite images
2
+ for the map."""
3
+
4
+ import os
5
+
6
+ import cv2
7
+ from pygmdl import save_image # type: ignore
8
+
9
+ from maps4fs.generator.background import DEFAULT_DISTANCE
10
+ from maps4fs.generator.component import Component
11
+ from maps4fs.generator.texture import PREVIEW_MAXIMUM_SIZE
12
+
13
+
14
+ # pylint: disable=W0223
15
+ class Satellite(Component):
16
+ """Component for to download satellite images for the map.
17
+
18
+ Arguments:
19
+ game (Game): The game instance for which the map is generated.
20
+ coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
21
+ map_size (int): The size of the map in pixels.
22
+ map_rotated_size (int): The size of the map in pixels after rotation.
23
+ rotation (int): The rotation angle of the map.
24
+ map_directory (str): The directory where the map files are stored.
25
+ logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
26
+ info, warning. If not provided, default logging will be used.
27
+ """
28
+
29
+ def preprocess(self) -> None:
30
+ """This component does not require any preprocessing."""
31
+ return
32
+
33
+ def process(self) -> None:
34
+ """Downloads the satellite images for the map."""
35
+ self.image_paths = [] # pylint: disable=W0201
36
+ if not self.map.satellite_settings.download_images:
37
+ self.logger.debug("Satellite images download is disabled.")
38
+ return
39
+
40
+ margin = self.map.satellite_settings.satellite_margin
41
+ overview_size = (self.map_size + margin) * 2
42
+ overwiew_path = os.path.join(self.satellite_directory, "satellite_overview.png")
43
+
44
+ background_size = self.map_size + (DEFAULT_DISTANCE + margin) * 2
45
+ background_path = os.path.join(self.satellite_directory, "satellite_background.png")
46
+
47
+ sizes = [overview_size, background_size]
48
+ self.image_paths = [overwiew_path, background_path] # pylint: disable=W0201
49
+
50
+ for size, path in zip(sizes, self.image_paths):
51
+ try:
52
+ lat, lon = self.coordinates
53
+ zoom = self.map.satellite_settings.zoom_level
54
+ save_image(
55
+ lat,
56
+ lon,
57
+ size,
58
+ output_path=path,
59
+ rotation=self.rotation,
60
+ zoom=zoom,
61
+ from_center=True,
62
+ logger=self.logger,
63
+ )
64
+ except Exception as e: # pylint: disable=W0718
65
+ self.logger.error(f"Failed to download satellite image: {e}")
66
+ continue
67
+
68
+ # pylint: disable=no-member
69
+ def previews(self) -> list[str]:
70
+ """Returns the paths to the preview images.
71
+
72
+ Returns:
73
+ list[str]: List of paths to the preview images.
74
+ """
75
+ previews = []
76
+ for image_path in self.image_paths:
77
+ if not os.path.isfile(image_path):
78
+ self.logger.warning(f"File {image_path} does not exist.")
79
+ continue
80
+ image = cv2.imread(image_path)
81
+ if image is None:
82
+ self.logger.warning(f"Failed to read image from {image_path}")
83
+ continue
84
+
85
+ if image.shape[0] > PREVIEW_MAXIMUM_SIZE or image.shape[1] > PREVIEW_MAXIMUM_SIZE:
86
+ image = cv2.resize(image, (PREVIEW_MAXIMUM_SIZE, PREVIEW_MAXIMUM_SIZE))
87
+
88
+ preview_path = os.path.join(self.previews_directory, os.path.basename(image_path))
89
+ cv2.imwrite(preview_path, image)
90
+ previews.append(preview_path)
91
+
92
+ return previews