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.
@@ -0,0 +1,470 @@
1
+ """This module contains the GRLE class for generating InfoLayer PNG files based on GRLE schema."""
2
+
3
+ import json
4
+ import os
5
+ from random import choice, randint
6
+ from xml.etree import ElementTree as ET
7
+
8
+ import cv2
9
+ import numpy as np
10
+ from shapely.geometry import Polygon # type: ignore
11
+ from tqdm import tqdm
12
+
13
+ from maps4fs.generator.component import Component
14
+ from maps4fs.generator.texture import PREVIEW_MAXIMUM_SIZE, Texture
15
+
16
+ ISLAND_DISTORTION = 0.3
17
+
18
+
19
+ def plant_to_pixel_value(plant_name: str) -> int | None:
20
+ """Returns the pixel value representation of the plant.
21
+ If not found, returns None.
22
+
23
+ Arguments:
24
+ plant_name (str): name of the plant
25
+
26
+ Returns:
27
+ int | None: pixel value of the plant or None if not found.
28
+ """
29
+ plants = {
30
+ "smallDenseMix": 33,
31
+ "meadow": 131,
32
+ }
33
+ return plants.get(plant_name)
34
+
35
+
36
+ # pylint: disable=W0223
37
+ class GRLE(Component):
38
+ """Component for to generate InfoLayer PNG files based on GRLE schema.
39
+
40
+ Arguments:
41
+ game (Game): The game instance for which the map is generated.
42
+ coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
43
+ map_size (int): The size of the map in pixels.
44
+ map_rotated_size (int): The size of the map in pixels after rotation.
45
+ rotation (int): The rotation angle of the map.
46
+ map_directory (str): The directory where the map files are stored.
47
+ logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
48
+ info, warning. If not provided, default logging will be used.
49
+ """
50
+
51
+ _grle_schema: dict[str, float | int | str] | None = None
52
+
53
+ def preprocess(self) -> None:
54
+ """Gets the path to the map I3D file from the game instance and saves it to the instance
55
+ attribute. If the game does not support I3D files, the attribute is set to None."""
56
+ self.preview_paths: dict[str, str] = {}
57
+
58
+ try:
59
+ grle_schema_path = self.game.grle_schema
60
+ except ValueError:
61
+ self.logger.warning("GRLE schema processing is not implemented for this game.")
62
+ return
63
+
64
+ try:
65
+ with open(grle_schema_path, "r", encoding="utf-8") as file:
66
+ self._grle_schema = json.load(file)
67
+ self.logger.debug("GRLE schema loaded from: %s.", grle_schema_path)
68
+ except (json.JSONDecodeError, FileNotFoundError) as error:
69
+ self.logger.error("Error loading GRLE schema from %s: %s.", grle_schema_path, error)
70
+ self._grle_schema = None
71
+
72
+ def process(self) -> None:
73
+ """Generates InfoLayer PNG files based on the GRLE schema."""
74
+ if not self._grle_schema:
75
+ self.logger.debug("GRLE schema is not obtained, skipping the processing.")
76
+ return
77
+
78
+ for info_layer in tqdm(self._grle_schema, desc="Preparing GRLE files", unit="layer"):
79
+ if isinstance(info_layer, dict):
80
+ file_path = os.path.join(
81
+ self.game.weights_dir_path(self.map_directory), info_layer["name"]
82
+ )
83
+
84
+ height = int(self.map_size * info_layer["height_multiplier"])
85
+ width = int(self.map_size * info_layer["width_multiplier"])
86
+ channels = info_layer["channels"]
87
+ data_type = info_layer["data_type"]
88
+
89
+ # Create the InfoLayer PNG file with zeros.
90
+ if channels == 1:
91
+ info_layer_data = np.zeros((height, width), dtype=data_type)
92
+ else:
93
+ info_layer_data = np.zeros((height, width, channels), dtype=data_type)
94
+ self.logger.debug("Shape of %s: %s.", info_layer["name"], info_layer_data.shape)
95
+ cv2.imwrite(file_path, info_layer_data) # pylint: disable=no-member
96
+ self.logger.debug("InfoLayer PNG file %s created.", file_path)
97
+ else:
98
+ self.logger.warning("Invalid InfoLayer schema: %s.", info_layer)
99
+
100
+ self._add_farmlands()
101
+ if self.game.code == "FS25":
102
+ self.logger.debug("Game is %s, plants will be added.", self.game.code)
103
+ self._add_plants()
104
+ else:
105
+ self.logger.warning("Adding plants it's not supported for the %s.", self.game.code)
106
+
107
+ # pylint: disable=no-member
108
+ def previews(self) -> list[str]:
109
+ """Returns a list of paths to the preview images (empty list).
110
+ The component does not generate any preview images so it returns an empty list.
111
+
112
+ Returns:
113
+ list[str]: An empty list.
114
+ """
115
+ preview_paths = []
116
+ for preview_name, preview_path in self.preview_paths.items():
117
+ save_path = os.path.join(self.previews_directory, f"{preview_name}.png")
118
+ # Resize the preview image to the maximum size allowed for previews.
119
+ image = cv2.imread(preview_path, cv2.IMREAD_GRAYSCALE)
120
+ if image.shape[0] > PREVIEW_MAXIMUM_SIZE or image.shape[1] > PREVIEW_MAXIMUM_SIZE:
121
+ image = cv2.resize(image, (PREVIEW_MAXIMUM_SIZE, PREVIEW_MAXIMUM_SIZE))
122
+ image_normalized = np.empty_like(image)
123
+ cv2.normalize(image, image_normalized, 0, 255, cv2.NORM_MINMAX)
124
+ image_colored = cv2.applyColorMap(image_normalized, cv2.COLORMAP_JET)
125
+ cv2.imwrite(save_path, image_colored)
126
+ preview_paths.append(save_path)
127
+
128
+ with_fields_save_path = os.path.join(
129
+ self.previews_directory, f"{preview_name}_with_fields.png"
130
+ )
131
+ image_with_fields = self.overlay_fields(image_colored)
132
+ if image_with_fields is None:
133
+ continue
134
+ cv2.imwrite(with_fields_save_path, image_with_fields) # pylint: disable=no-member
135
+ preview_paths.append(with_fields_save_path)
136
+
137
+ return preview_paths
138
+
139
+ def overlay_fields(self, farmlands_np: np.ndarray) -> np.ndarray | None:
140
+ """Overlay fields on the farmlands preview image.
141
+
142
+ Arguments:
143
+ farmlands_np (np.ndarray): The farmlands preview image.
144
+
145
+ Returns:
146
+ np.ndarray | None: The farmlands preview image with fields overlayed on top of it.
147
+ """
148
+ texture_component: Texture | None = self.map.get_component("Texture") # type: ignore
149
+ if not texture_component:
150
+ self.logger.warning("Texture component not found in the map.")
151
+ return None
152
+
153
+ fields_layer = texture_component.get_layer_by_usage("field")
154
+ fields_layer_path = fields_layer.get_preview_or_path( # type: ignore
155
+ self.game.weights_dir_path(self.map_directory)
156
+ )
157
+ if not fields_layer_path or not os.path.isfile(fields_layer_path):
158
+ self.logger.warning("Fields layer not found in the texture component.")
159
+ return None
160
+ fields_np = cv2.imread(fields_layer_path)
161
+ # Resize fields_np to the same size as farmlands_np.
162
+ fields_np = cv2.resize(fields_np, (farmlands_np.shape[1], farmlands_np.shape[0]))
163
+
164
+ # use fields_np as base layer and overlay farmlands_np on top of it with 50% alpha blending.
165
+ return cv2.addWeighted(fields_np, 0.5, farmlands_np, 0.5, 0)
166
+
167
+ # pylint: disable=R0801, R0914, R0915
168
+ def _add_farmlands(self) -> None:
169
+ """Adds farmlands to the InfoLayer PNG file."""
170
+
171
+ textures_info_layer_path = self.get_infolayer_path("textures")
172
+ if not textures_info_layer_path:
173
+ return
174
+
175
+ with open(textures_info_layer_path, "r", encoding="utf-8") as textures_info_layer_file:
176
+ textures_info_layer = json.load(textures_info_layer_file)
177
+
178
+ farmlands = []
179
+ farmyards: list[list[tuple[int, int]]] | None = textures_info_layer.get("farmyards")
180
+ if farmyards and self.map.grle_settings.add_farmyards:
181
+ farmlands.extend(farmyards)
182
+ self.logger.debug("Found %s farmyards in textures info layer.", len(farmyards))
183
+
184
+ fields: list[list[tuple[int, int]]] | None = textures_info_layer.get("fields")
185
+ if not fields:
186
+ self.logger.warning("Fields data not found in textures info layer.")
187
+ return
188
+ farmlands.extend(fields)
189
+
190
+ self.logger.debug("Found %s fields in textures info layer.", len(fields))
191
+
192
+ info_layer_farmlands_path = os.path.join(
193
+ self.game.weights_dir_path(self.map_directory), "infoLayer_farmlands.png"
194
+ )
195
+
196
+ self.logger.debug(
197
+ "Adding farmlands to the InfoLayer PNG file: %s.", info_layer_farmlands_path
198
+ )
199
+
200
+ if not os.path.isfile(info_layer_farmlands_path):
201
+ self.logger.warning("InfoLayer PNG file %s not found.", info_layer_farmlands_path)
202
+ return
203
+
204
+ # pylint: disable=no-member
205
+ image = cv2.imread(info_layer_farmlands_path, cv2.IMREAD_UNCHANGED)
206
+ farmlands_xml_path = os.path.join(self.map_directory, "map/config/farmlands.xml")
207
+ if not os.path.isfile(farmlands_xml_path):
208
+ self.logger.warning("Farmlands XML file %s not found.", farmlands_xml_path)
209
+ return
210
+
211
+ tree = ET.parse(farmlands_xml_path)
212
+ farmlands_xml = tree.find("farmlands")
213
+
214
+ # Not using enumerate because in case of the error, we do not increment
215
+ # the farmland_id. So as a result we do not have a gap in the farmland IDs.
216
+ farmland_id = 1
217
+
218
+ for farmland_data in tqdm(farmlands, desc="Adding farmlands", unit="farmland"):
219
+ try:
220
+ fitted_field = self.fit_object_into_bounds(
221
+ polygon_points=farmland_data,
222
+ margin=self.map.grle_settings.farmland_margin,
223
+ angle=self.rotation,
224
+ )
225
+ except ValueError as e:
226
+ self.logger.debug(
227
+ "Farmland %s could not be fitted into the map bounds with error: %s",
228
+ farmland_id,
229
+ e,
230
+ )
231
+ continue
232
+
233
+ self.logger.debug("Fitted field %s contains %s points.", farmland_id, len(fitted_field))
234
+
235
+ field_np = np.array(fitted_field, np.int32)
236
+ field_np = field_np.reshape((-1, 1, 2))
237
+
238
+ self.logger.debug(
239
+ "Created a numpy array and reshaped it. Number of points: %s", len(field_np)
240
+ )
241
+
242
+ # Infolayer image is 1/2 of the size of the map image, that's why we need to divide
243
+ # the coordinates by 2.
244
+ field_np = field_np // 2
245
+ self.logger.debug("Divided the coordinates by 2.")
246
+
247
+ # pylint: disable=no-member
248
+ try:
249
+ cv2.fillPoly(image, [field_np], farmland_id) # type: ignore
250
+ except Exception as e: # pylint: disable=W0718
251
+ self.logger.debug(
252
+ "Farmland %s could not be added to the InfoLayer PNG file with error: %s",
253
+ farmland_id,
254
+ e,
255
+ )
256
+ continue
257
+
258
+ # Add the field to the farmlands XML.
259
+ farmland = ET.SubElement(farmlands_xml, "farmland") # type: ignore
260
+ farmland.set("id", str(farmland_id))
261
+ farmland.set("priceScale", "1")
262
+ farmland.set("npcName", "FORESTER")
263
+
264
+ farmland_id += 1
265
+
266
+ tree.write(farmlands_xml_path)
267
+
268
+ self.logger.debug("Farmlands added to the farmlands XML file: %s.", farmlands_xml_path)
269
+
270
+ cv2.imwrite(info_layer_farmlands_path, image) # pylint: disable=no-member
271
+ self.logger.debug(
272
+ "Farmlands added to the InfoLayer PNG file: %s.", info_layer_farmlands_path
273
+ )
274
+
275
+ self.preview_paths["farmlands"] = info_layer_farmlands_path # type: ignore
276
+
277
+ # pylint: disable=R0915
278
+ def _add_plants(self) -> None:
279
+ """Adds plants to the InfoLayer PNG file."""
280
+ # 1. Get the path to the densityMap_fruits.png.
281
+ # 2. Get the path to the base layer (grass).
282
+ # 3. Detect non-zero areas in the base layer (it's where the plants will be placed).
283
+ texture_component: Texture | None = self.map.get_component("Texture") # type: ignore
284
+ if not texture_component:
285
+ self.logger.warning("Texture component not found in the map.")
286
+ return
287
+
288
+ grass_layer = texture_component.get_layer_by_usage("grass")
289
+ if not grass_layer:
290
+ self.logger.warning("Grass layer not found in the texture component.")
291
+ return
292
+
293
+ weights_directory = self.game.weights_dir_path(self.map_directory)
294
+ grass_image_path = grass_layer.get_preview_or_path(weights_directory)
295
+ self.logger.debug("Grass image path: %s.", grass_image_path)
296
+
297
+ forest_layer = texture_component.get_layer_by_usage("forest")
298
+ forest_image = None
299
+ if forest_layer:
300
+ forest_image_path = forest_layer.get_preview_or_path(weights_directory)
301
+ self.logger.debug("Forest image path: %s.", forest_image_path)
302
+ if forest_image_path:
303
+ # pylint: disable=no-member
304
+ forest_image = cv2.imread(forest_image_path, cv2.IMREAD_UNCHANGED)
305
+
306
+ if not grass_image_path or not os.path.isfile(grass_image_path):
307
+ self.logger.warning("Base image not found in %s.", grass_image_path)
308
+ return
309
+
310
+ density_map_fruit_path = os.path.join(
311
+ self.game.weights_dir_path(self.map_directory), "densityMap_fruits.png"
312
+ )
313
+
314
+ self.logger.debug("Density map for fruits path: %s.", density_map_fruit_path)
315
+
316
+ if not os.path.isfile(density_map_fruit_path):
317
+ self.logger.warning("Density map for fruits not found in %s.", density_map_fruit_path)
318
+ return
319
+
320
+ # Single channeled 8-bit image, where non-zero values (255) are where the grass is.
321
+ grass_image = cv2.imread( # pylint: disable=no-member
322
+ grass_image_path, cv2.IMREAD_UNCHANGED # pylint: disable=no-member
323
+ )
324
+
325
+ # Density map of the fruits is 2X size of the base image, so we need to resize it.
326
+ # We'll resize the base image to make it bigger, so we can compare the values.
327
+ grass_image = cv2.resize( # pylint: disable=no-member
328
+ grass_image,
329
+ (grass_image.shape[1] * 2, grass_image.shape[0] * 2),
330
+ interpolation=cv2.INTER_NEAREST, # pylint: disable=no-member
331
+ )
332
+ if forest_image is not None:
333
+ forest_image = cv2.resize( # pylint: disable=no-member
334
+ forest_image,
335
+ (forest_image.shape[1] * 2, forest_image.shape[0] * 2),
336
+ interpolation=cv2.INTER_NEAREST, # pylint: disable=no-member
337
+ )
338
+
339
+ # Add non zero values from the forest image to the grass image.
340
+ grass_image[forest_image != 0] = 255
341
+
342
+ # B and G channels remain the same (zeros), while we change the R channel.
343
+ possible_R_values = [65, 97, 129, 161, 193, 225] # pylint: disable=C0103
344
+
345
+ base_layer_pixel_value = plant_to_pixel_value(
346
+ self.map.grle_settings.base_grass # type:ignore
347
+ )
348
+ if not base_layer_pixel_value:
349
+ base_layer_pixel_value = 131
350
+
351
+ # pylint: disable=no-member
352
+ def create_island_of_plants(image: np.ndarray, count: int) -> np.ndarray:
353
+ """Create an island of plants in the image.
354
+
355
+ Arguments:
356
+ image (np.ndarray): The image where the island of plants will be created.
357
+ count (int): The number of islands of plants to create.
358
+
359
+ Returns:
360
+ np.ndarray: The image with the islands of plants.
361
+ """
362
+ for _ in tqdm(range(count), desc="Adding islands of plants", unit="island"):
363
+ # Randomly choose the value for the island.
364
+ plant_value = choice(possible_R_values)
365
+ # Randomly choose the size of the island.
366
+ island_size = randint(
367
+ self.map.grle_settings.plants_island_minimum_size, # type:ignore
368
+ self.map.grle_settings.plants_island_maximum_size, # type:ignore
369
+ )
370
+ # Randomly choose the position of the island.
371
+ x = randint(0, image.shape[1] - island_size)
372
+ y = randint(0, image.shape[0] - island_size)
373
+
374
+ try:
375
+ polygon_points = get_rounded_polygon(
376
+ num_vertices=self.map.grle_settings.plants_island_vertex_count,
377
+ center=(x + island_size // 2, y + island_size // 2),
378
+ radius=island_size // 2,
379
+ rounding_radius=self.map.grle_settings.plants_island_rounding_radius,
380
+ )
381
+ if not polygon_points:
382
+ continue
383
+
384
+ nodes = np.array(polygon_points, np.int32) # type: ignore
385
+ cv2.fillPoly(image, [nodes], plant_value) # type: ignore
386
+ except Exception: # pylint: disable=W0703
387
+ continue
388
+
389
+ return image
390
+
391
+ def get_rounded_polygon(
392
+ num_vertices: int, center: tuple[int, int], radius: int, rounding_radius: int
393
+ ) -> list[tuple[int, int]] | None:
394
+ """Get a randomly rounded polygon.
395
+
396
+ Arguments:
397
+ num_vertices (int): The number of vertices of the polygon.
398
+ center (tuple[int, int]): The center of the polygon.
399
+ radius (int): The radius of the polygon.
400
+ rounding_radius (int): The rounding radius of the polygon.
401
+
402
+ Returns:
403
+ list[tuple[int, int]] | None: The rounded polygon.
404
+ """
405
+ angle_offset = np.pi / num_vertices
406
+ angles = np.linspace(0, 2 * np.pi, num_vertices, endpoint=False) + angle_offset
407
+ random_angles = angles + np.random.uniform(
408
+ -ISLAND_DISTORTION, ISLAND_DISTORTION, num_vertices
409
+ ) # Add randomness to angles
410
+ random_radii = radius + np.random.uniform(
411
+ -radius * ISLAND_DISTORTION, radius * ISLAND_DISTORTION, num_vertices
412
+ ) # Add randomness to radii
413
+
414
+ points = [
415
+ (center[0] + np.cos(a) * r, center[1] + np.sin(a) * r)
416
+ for a, r in zip(random_angles, random_radii)
417
+ ]
418
+ polygon = Polygon(points)
419
+ buffered_polygon = polygon.buffer(rounding_radius, resolution=16)
420
+ rounded_polygon = list(buffered_polygon.exterior.coords)
421
+ if not rounded_polygon:
422
+ return None
423
+ return rounded_polygon
424
+
425
+ grass_image_copy = grass_image.copy()
426
+ if forest_image is not None:
427
+ # Add the forest layer to the base image, to merge the masks.
428
+ grass_image_copy[forest_image != 0] = base_layer_pixel_value
429
+
430
+ grass_image_copy[grass_image != 0] = base_layer_pixel_value
431
+
432
+ # Add islands of plants to the base image.
433
+ island_count = int(self.map_size * self.map.grle_settings.plants_island_percent // 100)
434
+ self.logger.debug("Adding %s islands of plants to the base image.", island_count)
435
+ if self.map.grle_settings.random_plants:
436
+ grass_image_copy = create_island_of_plants(grass_image_copy, island_count)
437
+ self.logger.debug("Added %s islands of plants to the base image.", island_count)
438
+
439
+ # Sligtly reduce the size of the grass_image, that we'll use as mask.
440
+ kernel = np.ones((3, 3), np.uint8)
441
+ grass_image = cv2.erode(grass_image, kernel, iterations=1)
442
+
443
+ # Remove the values where the base image has zeros.
444
+ grass_image_copy[grass_image == 0] = 0
445
+ self.logger.debug("Removed the values where the base image has zeros.")
446
+
447
+ # Set zeros on all sides of the image
448
+ grass_image_copy[0, :] = 0 # Top side
449
+ grass_image_copy[-1, :] = 0 # Bottom side
450
+ grass_image_copy[:, 0] = 0 # Left side
451
+ grass_image_copy[:, -1] = 0 # Right side
452
+
453
+ # After painting it with base grass, we'll create multiple islands of different plants.
454
+ # On the final step, we'll remove all the values which in pixels
455
+ # where zerons in the original base image (so we don't paint grass where it should not be).
456
+
457
+ # Three channeled 8-bit image, where non-zero values are the
458
+ # different types of plants (only in the R channel).
459
+ density_map_fruits = cv2.imread(density_map_fruit_path, cv2.IMREAD_UNCHANGED)
460
+ self.logger.debug("Density map for fruits loaded, shape: %s.", density_map_fruits.shape)
461
+
462
+ # Put the updated base image as the B channel in the density map.
463
+ density_map_fruits[:, :, 0] = grass_image_copy
464
+ self.logger.debug("Updated base image added as the B channel in the density map.")
465
+
466
+ # Save the updated density map.
467
+ # Ensure that order of channels is correct because CV2 uses BGR and we need RGB.
468
+ density_map_fruits = cv2.cvtColor(density_map_fruits, cv2.COLOR_BGR2RGB)
469
+ cv2.imwrite(density_map_fruit_path, density_map_fruits)
470
+ self.logger.debug("Updated density map for fruits saved in %s.", density_map_fruit_path)