maps4fs 1.5.7__py3-none-any.whl → 1.7.1__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
@@ -81,6 +77,20 @@ class I3d(Component):
81
77
 
82
78
  root = tree.getroot()
83
79
  for map_elem in root.iter("Scene"):
80
+ for terrain_elem in map_elem.iter("TerrainTransformGroup"):
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
+ )
92
+
93
+ self.logger.debug("TerrainTransformGroup element updated in I3D file.")
84
94
  sun_elem = map_elem.find(".//Light[@name='sun']")
85
95
 
86
96
  if sun_elem is not None:
@@ -97,10 +107,6 @@ class I3d(Component):
97
107
  )
98
108
 
99
109
  if self.map_size > 4096:
100
- terrain_elem = root.find(".//TerrainTransformGroup")
101
- if terrain_elem is None:
102
- self.logger.warning("TerrainTransformGroup element not found in I3D file.")
103
- return
104
110
  displacement_layer = terrain_elem.find(".//DisplacementLayer") # pylint: disable=W0631
105
111
 
106
112
  if displacement_layer is not None:
@@ -146,7 +152,7 @@ class I3d(Component):
146
152
  self.logger.warning("Roads polylines data not found in textures info layer.")
147
153
  return
148
154
 
149
- 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))
150
156
  self.logger.debug("Starging to add roads polylines to the I3D file.")
151
157
 
152
158
  root = tree.getroot()
@@ -188,7 +194,7 @@ class I3d(Component):
188
194
  linestring_points=road, angle=self.rotation
189
195
  )
190
196
  except ValueError as e:
191
- self.logger.warning(
197
+ self.logger.debug(
192
198
  "Road %s could not be fitted into the map bounds with error: %s",
193
199
  road_id,
194
200
  e,
@@ -239,7 +245,7 @@ class I3d(Component):
239
245
  y = max(0, min(y, dem_y_size - 1))
240
246
 
241
247
  z = not_resized_dem[y, x]
242
- z /= 32 # Yes, it's a magic number here.
248
+ z *= self.get_z_scaling_factor() # type: ignore
243
249
 
244
250
  cv_node = ET.Element("cv")
245
251
  cv_node.set("c", f"{cx}, {z}, {cy}")
@@ -291,7 +297,7 @@ class I3d(Component):
291
297
  self.logger.warning("Fields data not found in textures info layer.")
292
298
  return
293
299
 
294
- 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))
295
301
  self.logger.debug("Starging to add fields to the I3D file.")
296
302
 
297
303
  root = tree.getroot()
@@ -313,7 +319,7 @@ class I3d(Component):
313
319
  polygon_points=field, angle=self.rotation
314
320
  )
315
321
  except ValueError as e:
316
- self.logger.warning(
322
+ self.logger.debug(
317
323
  "Field %s could not be fitted into the map bounds with error: %s",
318
324
  field_id,
319
325
  e,
@@ -327,7 +333,7 @@ class I3d(Component):
327
333
  try:
328
334
  cx, cy = self.get_polygon_center(field_ccs)
329
335
  except Exception as e: # pylint: disable=W0718
330
- self.logger.warning(
336
+ self.logger.debug(
331
337
  "Field %s could not be fitted into the map bounds.", field_id
332
338
  )
333
339
  self.logger.debug("Error: %s", e)
maps4fs/generator/map.py CHANGED
@@ -8,7 +8,7 @@ import shutil
8
8
  from typing import Any, Generator
9
9
 
10
10
  from maps4fs.generator.component import Component
11
- from maps4fs.generator.dtm import DTMProvider, DTMProviderSettings
11
+ from maps4fs.generator.dtm.dtm import DTMProvider, DTMProviderSettings
12
12
  from maps4fs.generator.game import Game
13
13
  from maps4fs.generator.settings import (
14
14
  BackgroundSettings,
@@ -16,6 +16,7 @@ from maps4fs.generator.settings import (
16
16
  GRLESettings,
17
17
  I3DSettings,
18
18
  SatelliteSettings,
19
+ SharedSettings,
19
20
  SplineSettings,
20
21
  TextureSettings,
21
22
  )
@@ -88,6 +89,17 @@ class Map:
88
89
 
89
90
  self.dem_settings = dem_settings
90
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
+
91
103
  self.background_settings = background_settings
92
104
  self.logger.info("Background settings: %s", background_settings)
93
105
  self.grle_settings = grle_settings
@@ -123,6 +135,8 @@ class Map:
123
135
  with open(save_path, "w", encoding="utf-8") as file:
124
136
  json.dump(settings_json, file, indent=4)
125
137
 
138
+ self.shared_settings = SharedSettings()
139
+
126
140
  self.texture_custom_schema = kwargs.get("texture_custom_schema", None)
127
141
  if self.texture_custom_schema:
128
142
  save_path = os.path.join(self.map_directory, "texture_custom_schema.json")
@@ -132,11 +146,17 @@ class Map:
132
146
 
133
147
  self.tree_custom_schema = kwargs.get("tree_custom_schema", None)
134
148
  if self.tree_custom_schema:
149
+ self.logger.info("Custom tree schema contains %s trees", len(self.tree_custom_schema))
135
150
  save_path = os.path.join(self.map_directory, "tree_custom_schema.json")
136
151
  with open(save_path, "w", encoding="utf-8") as file:
137
152
  json.dump(self.tree_custom_schema, file, indent=4)
138
153
  self.logger.debug("Tree custom schema saved to %s", save_path)
139
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
+
140
160
  try:
141
161
  shutil.unpack_archive(game.template_path, self.map_directory)
142
162
  self.logger.debug("Map template unpacked to %s", self.map_directory)
@@ -245,7 +265,7 @@ class Map:
245
265
  str: Path to the archive.
246
266
  """
247
267
  archive_path = shutil.make_archive(archive_path, "zip", self.map_directory)
248
- self.logger.info("Map packed to %s.zip", archive_path)
268
+ self.logger.debug("Map packed to %s.zip", archive_path)
249
269
  if remove_source:
250
270
  try:
251
271
  shutil.rmtree(self.map_directory)
@@ -34,7 +34,7 @@ class Satellite(Component):
34
34
  """Downloads the satellite images for the map."""
35
35
  self.image_paths = [] # pylint: disable=W0201
36
36
  if not self.map.satellite_settings.download_images:
37
- self.logger.info("Satellite images download is disabled.")
37
+ self.logger.debug("Satellite images download is disabled.")
38
38
  return
39
39
 
40
40
  margin = self.map.satellite_settings.satellite_margin
@@ -4,12 +4,29 @@ from __future__ import annotations
4
4
 
5
5
  from typing import Any
6
6
 
7
- from pydantic import BaseModel
7
+ from pydantic import BaseModel, ConfigDict
8
+
9
+
10
+ class SharedSettings(BaseModel):
11
+ """Represents the shared settings for all components."""
12
+
13
+ mesh_z_scaling_factor: float | None = None
14
+ height_scale_multiplier: float | None = None
15
+ height_scale_value: float | None = None
16
+ change_height_scale: bool = False
17
+
18
+ model_config = ConfigDict(
19
+ frozen=False,
20
+ )
8
21
 
9
22
 
10
23
  class SettingsModel(BaseModel):
11
24
  """Base class for settings models. It provides methods to convert settings to and from JSON."""
12
25
 
26
+ model_config = ConfigDict(
27
+ frozen=False,
28
+ )
29
+
13
30
  @classmethod
14
31
  def all_settings_to_json(cls) -> dict[str, dict[str, Any]]:
15
32
  """Get all settings of the current class and its subclasses as a dictionary.
@@ -25,18 +42,28 @@ class SettingsModel(BaseModel):
25
42
  return all_settings
26
43
 
27
44
  @classmethod
28
- def all_settings_from_json(cls, data: dict) -> dict[str, SettingsModel]:
45
+ def all_settings_from_json(
46
+ cls, data: dict, flattening: bool = True
47
+ ) -> dict[str, SettingsModel]:
29
48
  """Create settings instances from JSON data.
30
49
 
31
50
  Arguments:
32
51
  data (dict): JSON data.
52
+ flattening (bool): if set to True will flattet iterables to use the first element
53
+ of it.
33
54
 
34
55
  Returns:
35
56
  dict[str, Type[SettingsModel]]: Dictionary with settings instances.
36
57
  """
37
58
  settings = {}
38
59
  for subclass in cls.__subclasses__():
39
- settings[subclass.__name__] = subclass(**data[subclass.__name__])
60
+ subclass_data = data[subclass.__name__]
61
+ if flattening:
62
+ for key, value in subclass_data.items():
63
+ if isinstance(value, (list, tuple)):
64
+ subclass_data[key] = value[0]
65
+
66
+ settings[subclass.__name__] = subclass(**subclass_data)
40
67
 
41
68
  return settings
42
69
 
@@ -85,6 +112,10 @@ class BackgroundSettings(SettingsModel):
85
112
  generate_background: bool = False
86
113
  generate_water: bool = False
87
114
  resize_factor: int = 8
115
+ remove_center: bool = False
116
+ apply_decimation: bool = False
117
+ decimation_percent: int = 25
118
+ decimation_agression: int = 3
88
119
 
89
120
 
90
121
  class GRLESettings(SettingsModel):
@@ -100,6 +131,12 @@ class GRLESettings(SettingsModel):
100
131
  farmland_margin: int = 0
101
132
  random_plants: bool = True
102
133
  add_farmyards: bool = False
134
+ base_grass: tuple | str = ("smallDenseMix", "meadow")
135
+ plants_island_minimum_size: int = 10
136
+ plants_island_maximum_size: int = 200
137
+ plants_island_vertex_count: int = 30
138
+ plants_island_rounding_radius: int = 15
139
+ plants_island_percent: int = 100
103
140
 
104
141
 
105
142
  class I3DSettings(SettingsModel):
@@ -146,5 +183,5 @@ class SatelliteSettings(SettingsModel):
146
183
  """
147
184
 
148
185
  download_images: bool = False
149
- satellite_margin: int = 100
186
+ satellite_margin: int = 0
150
187
  zoom_level: int = 14