maps4fs 1.8.0__py3-none-any.whl

Sign up to get free protection for your applications and to get access to all the features.
@@ -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)