From 31802cbed1f8600783247a13b45a5c9ae21adb39 Mon Sep 17 00:00:00 2001 From: Stan Soldatov <118521851+iwatkot@users.noreply.github.com> Date: Sat, 21 Dec 2024 21:12:37 +0100 Subject: [PATCH] Automatically add deco foliage * Version check dsiabled for SL community. * FAQ update. * Changing the Sun bbox. * Linter updates. * Generating deco foliage. * Rotation support for grass. * README update. --- README.md | 3 + data/fs25-texture-schema.json | 11 ++- docs/FAQ.md | 4 ++ maps4fs/generator/game.py | 15 ++++ maps4fs/generator/grle.py | 129 ++++++++++++++++++++++++++++++++++ maps4fs/generator/i3d.py | 15 ++++ maps4fs/generator/texture.py | 12 ++++ webui/generator.py | 42 +++++------ 8 files changed, 209 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index 01ec771..cd96971 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ 🔄 Support map rotation 🆕
🌾 Automatically generates fields 🆕
🌽 Automatically generates farmlands 🆕
+🌿 Automatically generates decorative foliage 🆕
🌍 Based on real-world data from OpenStreetMap
🏞️ Generates height map using SRTM dataset
📦 Provides a ready-to-use map template for the Giants Editor
@@ -59,6 +60,8 @@ 🛰️ Realistic background terrain with satellite images.


📐 Perfectly aligned background terrain.

+
+🌿 Automatically generates decorative foliage.


🗒️ True-to-life blueprints for fast and precise modding.


diff --git a/data/fs25-texture-schema.json b/data/fs25-texture-schema.json index 14554ac..998f6ea 100644 --- a/data/fs25-texture-schema.json +++ b/data/fs25-texture-schema.json @@ -166,7 +166,10 @@ }, { "name": "mudPebblesLight", - "count": 2 + "count": 2, + "tags": { "waterway": ["ditch", "drain"] }, + "width": 2, + "color": [33, 67, 101] }, { "name": "mudTracks", @@ -199,7 +202,11 @@ { "name": "sand", "count": 2, - "tags": { "natural": "water", "waterway": true, "landuse": "basin" }, + "tags": { + "natural": "water", + "waterway": ["river", "stream", "flowline", "tidal_channel", "canal"], + "landuse": "basin" + }, "width": 10, "color": [255, 20, 20] } diff --git a/docs/FAQ.md b/docs/FAQ.md index 77ee600..ceb0865 100644 --- a/docs/FAQ.md +++ b/docs/FAQ.md @@ -46,4 +46,8 @@ setShapeCullingWorldProperties(-8192, -100, -8192, 8192, 500, 8192, 16, 0, 9000) Make sure that **Always loaded** checkbox is checked, then save it and execute. It should help. +### I launched the script to download satellite images from QGIS, but it's taking too long, what should I do? + +The script is downloading a huge GeoTIFF image, so it can take a while depending on the region size and hardware. Some guys reported that on old CPUs it can take up to 30 minutes. Just wait, and it will finish eventually. + If you think that some question should be added here, please, contact me in [Discord](https://discord.gg/Sj5QKKyE42) or open an issue on GitHub. Thank you! \ No newline at end of file diff --git a/maps4fs/generator/game.py b/maps4fs/generator/game.py index e576a83..186154e 100644 --- a/maps4fs/generator/game.py +++ b/maps4fs/generator/game.py @@ -36,6 +36,7 @@ class Game: _map_template_path: str | None = None _texture_schema: str | None = None _grle_schema: str | None = None + _base_image: str | None = None # Order matters! Some components depend on others. components = [Texture, I3d, GRLE, Background, Config] @@ -130,6 +131,19 @@ def weights_dir_path(self, map_directory: str) -> str: str: The path to the weights directory.""" raise NotImplementedError + def base_image_path(self, map_directory: str) -> str | None: + """Returns the path to the base density map image. + + Arguments: + map_directory (str): The path to the map directory. + + Returns: + str: The path to the base density map image or None if not set. + """ + if self._base_image: + return os.path.join(self.weights_dir_path(map_directory), self._base_image) + return None + def i3d_file_path(self, map_directory: str) -> str: """Returns the path to the i3d file. @@ -187,6 +201,7 @@ class FS25(Game): _map_template_path = os.path.join(working_directory, "data", "fs25-map-template.zip") _texture_schema = os.path.join(working_directory, "data", "fs25-texture-schema.json") _grle_schema = os.path.join(working_directory, "data", "fs25-grle-schema.json") + _base_image = "base.png" def dem_file_path(self, map_directory: str) -> str: """Returns the path to the DEM file. diff --git a/maps4fs/generator/grle.py b/maps4fs/generator/grle.py index c48fd1e..5b33f29 100644 --- a/maps4fs/generator/grle.py +++ b/maps4fs/generator/grle.py @@ -2,6 +2,7 @@ import json import os +from random import choice, randint from xml.etree import ElementTree as ET import cv2 @@ -9,6 +10,9 @@ from maps4fs.generator.component import Component +ISLAND_SIZE_MIN = 10 +ISLAND_SIZE_MAX = 200 + # pylint: disable=W0223 class GRLE(Component): @@ -76,6 +80,11 @@ def process(self) -> None: self.logger.warning("Invalid InfoLayer schema: %s.", info_layer) self._add_farmlands() + if self.game.code == "FS25": + self.logger.info("Game is %s, plants will be added.", self.game.code) + self._add_plants() + else: + self.logger.warning("Adding plants it's not supported for the %s.", self.game.code) def previews(self) -> list[str]: """Returns a list of paths to the preview images (empty list). @@ -184,3 +193,123 @@ def _add_farmlands(self) -> None: self.logger.info( "Farmlands added to the InfoLayer PNG file: %s.", info_layer_farmlands_path ) + + def _add_plants(self) -> None: + """Adds plants to the InfoLayer PNG file.""" + # 1. Get the path to the densityMap_fruits.png. + # 2. Get the path to the base layer (grass). + # 3. Detect non-zero areas in the base layer (it's where the plants will be placed). + base_image_path = self.game.base_image_path(self.map_directory) + if not base_image_path or not os.path.isfile(base_image_path): + self.logger.warning("Base image not found in %s.", base_image_path) + return + + density_map_fruit_path = os.path.join( + self.game.weights_dir_path(self.map_directory), "densityMap_fruits.png" + ) + + if not os.path.isfile(density_map_fruit_path): + self.logger.warning("Density map for fruits not found in %s.", density_map_fruit_path) + return + + # Single channeled 8-bit image, where non-zero values (255) are where the grass is. + base_image = cv2.imread(base_image_path, cv2.IMREAD_UNCHANGED) # pylint: disable=no-member + + # Density map of the fruits is 2X size of the base image, so we need to resize it. + # We'll resize the base image to make it bigger, so we can compare the values. + base_image = cv2.resize( # pylint: disable=no-member + base_image, + (base_image.shape[1] * 2, base_image.shape[0] * 2), + interpolation=cv2.INTER_NEAREST, # pylint: disable=no-member + ) + + # B and G channels remain the same (zeros), while we change the R channel. + possible_R_values = [33, 65, 97, 129, 161, 193, 225] # pylint: disable=C0103 + + # 1st approach: Change the non zero values in the base image to 33 (for debug). + # And use the base image as R channel in the density map. + + # pylint: disable=no-member + def create_island_of_plants(image: np.ndarray, count: int) -> np.ndarray: + """Create an island of plants in the image. + + Arguments: + image (np.ndarray): The image where the island of plants will be created. + count (int): The number of islands of plants to create. + + Returns: + np.ndarray: The image with the islands of plants. + """ + for _ in range(count): + # Randomly choose the value for the island. + plant_value = choice(possible_R_values) + # Randomly choose the size of the island. + island_size = randint(ISLAND_SIZE_MIN, ISLAND_SIZE_MAX) + # Randomly choose the position of the island. + # x = np.random.randint(0, image.shape[1] - island_size) + # y = np.random.randint(0, image.shape[0] - island_size) + x = randint(0, image.shape[1] - island_size) + y = randint(0, image.shape[0] - island_size) + + # Randomly choose the shape of the island. + shapes = ["circle", "ellipse", "polygon"] + shape = choice(shapes) + + try: + if shape == "circle": + center = (x + island_size // 2, y + island_size // 2) + radius = island_size // 2 + cv2.circle(image, center, radius, plant_value, -1) # type: ignore + elif shape == "ellipse": + center = (x + island_size // 2, y + island_size // 2) + axes = (island_size // 2, island_size // 4) + angle = 0 + cv2.ellipse( # type: ignore + image, center, axes, angle, 0, 360, plant_value, -1 + ) + elif shape == "polygon": + nodes_count = randint(20, 50) + nodes = [] + for _ in range(nodes_count): + node = (randint(x, x + island_size), randint(y, y + island_size)) + nodes.append(node) + nodes = np.array(nodes, np.int32) # type: ignore + cv2.fillPoly(image, [nodes], plant_value) # type: ignore + except Exception: # pylint: disable=W0703 + continue + + return image + + updated_base_image = base_image.copy() + # Set all the non-zero values to 33. + updated_base_image[base_image != 0] = 33 + + # Add islands of plants to the base image. + island_count = self.map_size + self.logger.info("Adding %s islands of plants to the base image.", island_count) + updated_base_image = create_island_of_plants(updated_base_image, island_count) + self.logger.debug("Islands of plants added to the base image.") + + # Remove the values where the base image has zeros. + updated_base_image[base_image == 0] = 0 + self.logger.debug("Removed the values where the base image has zeros.") + + # Value of 33 represents the base grass plant. + # After painting it with base grass, we'll create multiple islands of different plants. + # On the final step, we'll remove all the values which in pixels + # where zerons in the original base image (so we don't paint grass where it should not be). + + # Three channeled 8-bit image, where non-zero values are the + # different types of plants (only in the R channel). + density_map_fruits = cv2.imread(density_map_fruit_path, cv2.IMREAD_UNCHANGED) + self.logger.debug("Density map for fruits loaded, shape: %s.", density_map_fruits.shape) + + # Put the updated base image as the B channel in the density map. + density_map_fruits[:, :, 0] = updated_base_image + self.logger.debug("Updated base image added as the B channel in the density map.") + + # Save the updated density map. + # Ensure that order of channels is correct because CV2 uses BGR and we need RGB. + density_map_fruits = cv2.cvtColor(density_map_fruits, cv2.COLOR_BGR2RGB) + cv2.imwrite(density_map_fruit_path, density_map_fruits) + self.logger.info("Updated density map for fruits saved in %s.", density_map_fruit_path) diff --git a/maps4fs/generator/i3d.py b/maps4fs/generator/i3d.py index e87c49c..2bb750a 100644 --- a/maps4fs/generator/i3d.py +++ b/maps4fs/generator/i3d.py @@ -85,6 +85,21 @@ def _update_i3d_file(self) -> None: self.logger.debug("TerrainTransformGroup element updated in I3D file.") + sun_elem = map_elem.find(".//Light[@name='sun']") + + if sun_elem is not None: + self.logger.debug("Sun element found in I3D file.") + + distance = self.map_size // 2 + + sun_elem.set("lastShadowMapSplitBboxMin", f"-{distance},-128,-{distance}") + sun_elem.set("lastShadowMapSplitBboxMax", f"{distance},148,{distance}") + + self.logger.debug( + "Sun BBOX updated with half of the map size: %s.", + distance, + ) + if self.map_size > 4096: displacement_layer = terrain_elem.find(".//DisplacementLayer") # pylint: disable=W0631 diff --git a/maps4fs/generator/texture.py b/maps4fs/generator/texture.py index b1ce7d8..9ae211c 100644 --- a/maps4fs/generator/texture.py +++ b/maps4fs/generator/texture.py @@ -5,6 +5,7 @@ import json import os import re +import shutil from collections import defaultdict from typing import Any, Callable, Generator, Optional @@ -242,6 +243,17 @@ def rotate_textures(self) -> None: "Skipping rotation of layer %s because it has no tags.", layer.name ) + base_path = self.game.base_image_path(self.map_directory) + if base_path: + base_layer = self.get_base_layer() + if base_layer: + base_layer_path = base_layer.get_preview_or_path(self._weights_dir) + self.logger.debug( + "Copying base layer to use it later for density map to %s.", base_path + ) + # Make a copy of a base layer to the fruits density map. + shutil.copy(base_layer_path, base_path) + # pylint: disable=W0201 def _read_parameters(self) -> None: """Reads map parameters from OSM data, such as: diff --git a/webui/generator.py b/webui/generator.py index 16c62e4..4ea0537 100644 --- a/webui/generator.py +++ b/webui/generator.py @@ -45,7 +45,7 @@ class GeneratorUI: def __init__(self): self.download_path = None - self.logger = mfs.Logger(level="DEBUG", to_file=False) + self.logger = mfs.Logger(level="INFO", to_file=False) self.community = config.is_on_community_server() self.logger.debug("The application launched on the community server: %s", self.community) @@ -116,25 +116,27 @@ def add_left_widgets(self) -> None: self.logger.debug("Adding widgets to the left column...") st.title(Messages.TITLE) - versions = config.get_versions(self.logger) - try: - if versions: - latest_version, current_version = versions - if current_version != latest_version and len(current_version) > 0: - st.warning( - f"🆕 New version is available! \n" - f"Your current version: `{current_version}`, " - f"latest version: `{latest_version}`. \n" - "Use the following commands to upgrade: \n" - "```bash \n" - "docker stop maps4fs \n" - "docker rm maps4fs \n" - "docker run -d -p 8501:8501 --name maps4fs " - f"iwatkot/maps4fs:{latest_version} \n" - "```" - ) - except Exception as e: - self.logger.error("An error occurred while checking the package version: %s", e) + + if not self.community: + versions = config.get_versions(self.logger) + try: + if versions: + latest_version, current_version = versions + if current_version != latest_version and len(current_version) > 0: + st.warning( + f"🆕 New version is available! \n" + f"Your current version: `{current_version}`, " + f"latest version: `{latest_version}`. \n" + "Use the following commands to upgrade: \n" + "```bash \n" + "docker stop maps4fs \n" + "docker rm maps4fs \n" + "docker run -d -p 8501:8501 --name maps4fs " + f"iwatkot/maps4fs:{latest_version} \n" + "```" + ) + except Exception as e: + self.logger.error("An error occurred while checking the package version: %s", e) st.write(Messages.MAIN_PAGE_DESCRIPTION) if self.community: