Skip to content

Commit

Permalink
Automatically add deco foliage
Browse files Browse the repository at this point in the history
* Version check dsiabled for SL community.

* FAQ update.

* Changing the Sun bbox.

* Linter updates.

* Generating deco foliage.

* Rotation support for grass.

* README update.
  • Loading branch information
iwatkot authored Dec 21, 2024
1 parent 4c0b8e1 commit 31802cb
Show file tree
Hide file tree
Showing 8 changed files with 209 additions and 22 deletions.
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
🔄 Support map rotation 🆕<br>
🌾 Automatically generates fields 🆕<br>
🌽 Automatically generates farmlands 🆕<br>
🌿 Automatically generates decorative foliage 🆕<br>
🌍 Based on real-world data from OpenStreetMap<br>
🏞️ Generates height map using SRTM dataset<br>
📦 Provides a ready-to-use map template for the Giants Editor<br>
Expand All @@ -59,6 +60,8 @@
🛰️ Realistic background terrain with satellite images.<br><br>
<img src="https://github.com/user-attachments/assets/6e3c0e99-2cce-46ac-82db-5cb60bba7a30"><br>
📐 Perfectly aligned background terrain.<br><br>
<img src="https://github.com/user-attachments/assets/5764b2ec-e626-426f-9f5d-beb12ba95133"><br>
🌿 Automatically generates decorative foliage.<br><br>
<img src="https://github.com/user-attachments/assets/80e5923c-22c7-4dc0-8906-680902511f3a"><br>
🗒️ True-to-life blueprints for fast and precise modding.<br><br>
<img width="480" src="https://github.com/user-attachments/assets/1a8802d2-6a3b-4bfa-af2b-7c09478e199b"><br>
Expand Down
11 changes: 9 additions & 2 deletions data/fs25-texture-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,10 @@
},
{
"name": "mudPebblesLight",
"count": 2
"count": 2,
"tags": { "waterway": ["ditch", "drain"] },
"width": 2,
"color": [33, 67, 101]
},
{
"name": "mudTracks",
Expand Down Expand Up @@ -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]
}
Expand Down
4 changes: 4 additions & 0 deletions docs/FAQ.md
Original file line number Diff line number Diff line change
Expand Up @@ -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!
15 changes: 15 additions & 0 deletions maps4fs/generator/game.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
129 changes: 129 additions & 0 deletions maps4fs/generator/grle.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,17 @@

import json
import os
from random import choice, randint
from xml.etree import ElementTree as ET

import cv2
import numpy as np

from maps4fs.generator.component import Component

ISLAND_SIZE_MIN = 10
ISLAND_SIZE_MAX = 200


# pylint: disable=W0223
class GRLE(Component):
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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)
15 changes: 15 additions & 0 deletions maps4fs/generator/i3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions maps4fs/generator/texture.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import json
import os
import re
import shutil
from collections import defaultdict
from typing import Any, Callable, Generator, Optional

Expand Down Expand Up @@ -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:
Expand Down
42 changes: 22 additions & 20 deletions webui/generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down

0 comments on commit 31802cb

Please sign in to comment.