diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 1f16da9e..adbe6554 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -3,7 +3,7 @@ name: Tests on: push: branches: - - main + - '**' jobs: check: diff --git a/.github/workflows/pypi.yml b/.github/workflows/pypi.yml new file mode 100644 index 00000000..7a29f6e4 --- /dev/null +++ b/.github/workflows/pypi.yml @@ -0,0 +1,24 @@ +name: Publish Python Package to PyPI +on: + release: + types: [published] +jobs: + build-and-publish: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Set up Python + uses: actions/setup-python@v2 + with: + python-version: '3.11' + - name: Install dependencies + run: | + python -m pip install --upgrade pip + python -m pip install build twine + - name: Build package + run: | + python -m build + - name: Publish package to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file diff --git a/.vscode/launch.json b/.vscode/launch.json index 3b040e41..a4681d1e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -1,18 +1,6 @@ { "version": "0.2.0", "configurations": [ - { - "name": "Linux / Mac Script", - "type": "python", - "request": "launch", - "program": "maps4fs/ui.py", - "console": "integratedTerminal", - "justMyCode": true, - "env": { - "PYTHONPATH": "${workspaceFolder}:${PYTHONPATH}", - "LOG_LEVEL": "DEBUG", - } - }, { "name": "Streamlit: webui", "type": "process", diff --git a/bot/bot.py b/bot/bot.py index 31b8bfbc..868110a1 100644 --- a/bot/bot.py +++ b/bot/bot.py @@ -17,6 +17,10 @@ logger = mfs.Logger(__name__, level="DEBUG") # region constants +GAME_CODES = { + "FS22": "Farming Simulator 22", + "FS25": "Farming Simulator 25", +} MAP_SIZES = ["2048", "4096", "8192", "16384"] MAX_HEIGHTS = { "100": "🍀 For flatlands", @@ -29,7 +33,6 @@ # region directories working_directory = os.getcwd() -map_template = os.path.join(working_directory, "data", "map-template.zip") maps_directory = os.path.join(working_directory, "maps") archives_directory = os.path.join(working_directory, "archives") stats_directory = os.path.join(working_directory, "stats") @@ -77,12 +80,13 @@ class Session: dem_settings (generate.DemSettings): DEM settings. """ - def __init__(self, telegram_id: int, coordinates: tuple[float, float]): + def __init__(self, telegram_id: int, game_code: str): self.telegram_id = telegram_id self.timestamp = int(datetime.now().timestamp()) self.name = f"{self.telegram_id}_{self.timestamp}" self.map_directory = os.path.join(maps_directory, self.name) - self.coordinates = coordinates + self.game = mfs.Game.from_code(game_code) + self.coordinates = None self.distance = None self.max_height = None @@ -93,17 +97,17 @@ def run(self) -> tuple[str, str]: tuple[str, str]: Paths to the preview and the archive. """ mp = mfs.Map( + self.game, self.coordinates, self.distance, self.map_directory, blur_seed=5, max_height=self.max_height, - map_template=map_template, logger=logger, ) mp.generate() # preview_path = mp.previews()[0] - preview_path = None # Disabled to avoid Docker 137 error (OUT OF MEMORY). + preview_path = None # Disabled to avoid Docker 137 error (OUT OF MEMORY). archive_path = mp.pack(os.path.join(archives_directory, self.name)) return preview_path, archive_path @@ -198,13 +202,38 @@ async def button_coffee(message: types.Message) -> None: @dp.message_handler(Text(equals=Buttons.GENERATE.value)) async def button_generate(message: types.Message) -> None: - """Handles the Generate button, registers the coordinates handler. + """Handles the Generate button, creates inline buttons for the game selection. Args: message (types.Message): Message, which triggered the handler. """ await log_event(message) + buttons = {} + for game_code, game_name in GAME_CODES.items(): + buttons[f"game_code_{game_code}"] = game_name + + await bot.send_message( + message.from_user.id, + Messages.CHOOSE_GAME.value, + reply_markup=await keyboard(buttons), + ) + + +@dp.callback_query_handler(text_contains="game_code_") +async def game_choose(message: types.Message) -> None: + """Handles the catch of the game selection, registers the coordinates handler. + + Args: + message (types.Message): Message, which triggered the handler. + """ + await log_event(message) + + game_code = message.data.rsplit("_", 1)[-1] + + session = Session(message.from_user.id, game_code) + sessions[message.from_user.id] = session + dp.register_message_handler(coordinates) await bot.send_message( @@ -263,8 +292,10 @@ async def coordinates(message: types.Message) -> None: ) return - telegram_id = message.from_user.id - sessions[telegram_id] = Session(telegram_id, (latitude, longitude)) + session = sessions.get(message.from_user.id) + if not session: + return + session.coordinates = (latitude, longitude) sizes = MAP_SIZES indicators = ["🟢", "🟢", "🟡", "🔴"] diff --git a/bot/bot_templates.py b/bot/bot_templates.py index 3cd69f04..2c408d87 100644 --- a/bot/bot_templates.py +++ b/bot/bot_templates.py @@ -17,6 +17,8 @@ class Messages(Enum): CANCELLED = "The operation has been cancelled." + CHOOSE_GAME = "Choose the game for which you want to generate a map." + ENTER_COORDINATES = ( "Enter the coordinates of the center of the map\." "The coordinates are latitude and longitude separated by a comma\.\n\n" diff --git a/data/map-template.zip b/data/fs22-map-template.zip similarity index 100% rename from data/map-template.zip rename to data/fs22-map-template.zip diff --git a/data/fs22-texture-schema.json b/data/fs22-texture-schema.json new file mode 100644 index 00000000..e37cccd4 --- /dev/null +++ b/data/fs22-texture-schema.json @@ -0,0 +1,96 @@ +[ + { + "name": "animalMud", + "count": 4 + }, + { + "name": "asphalt", + "count": 4, + "tags": { "highway": ["motorway", "trunk", "primary"] }, + "width": 8, + "color": [70, 70, 70] + }, + { + "name": "cobbleStone", + "count": 4 + }, + { + "name": "concrete", + "count": 4, + "tags": { "building": "true" }, + "width": 8, + "color": [130, 130, 130] + }, + { + "name": "concreteRubble", + "count": 4 + }, + { + "name": "concreteTiles", + "count": 4 + }, + { + "name": "dirt", + "count": 4 + }, + { + "name": "dirtDark", + "count": 2, + "tags": { "highway": ["unclassified", "residential", "track"] }, + "width": 2, + "color": [33, 67, 101] + }, + { + "name": "forestGround", + "count": 4, + "tags": { "landuse": "farmland" }, + "color": [47, 107, 85] + }, + { + "name": "forestGroundLeaves", + "count": 4 + }, + { + "name": "grass", + "count": 4, + "tags": { "natural": "grassland" }, + "color": [34, 255, 34] + }, + { + "name": "grassDirt", + "count": 4, + "tags": { "natural": ["wood", "tree_row"] }, + "width": 2, + "color": [0, 252, 124] + }, + { + "name": "gravel", + "count": 4, + "tags": { "highway": ["secondary", "tertiary", "road"] }, + "width": 4, + "color": [140, 180, 210] + }, + { + "name": "groundBricks", + "count": 4 + }, + { + "name": "mountainRock", + "count": 4 + }, + { + "name": "mountainRockDark", + "count": 4 + }, + { + "name": "riverMud", + "count": 4 + }, + { + "name": "waterPuddle", + "count": 0, + "tags": { "natural": "water", "waterway": "true" }, + "width": 10, + "color": [255, 20, 20] + } +] diff --git a/dev/clean_trash.sh b/dev/clean_trash.sh index 0760527f..c09f5a16 100644 --- a/dev/clean_trash.sh +++ b/dev/clean_trash.sh @@ -1,7 +1,7 @@ #!/bin/sh # Directories to be removed -dirs=".mypy_cache .pytest_cache htmlcov dist" +dirs=".mypy_cache .pytest_cache htmlcov dist archives cache logs maps temp" # Files to be removed files=".coverage" diff --git a/dev/requirements.txt b/dev/requirements.txt index 4c9c6813..6a98ee10 100644 --- a/dev/requirements.txt +++ b/dev/requirements.txt @@ -1,5 +1,5 @@ opencv-python -osmnx +osmnx<2.0.0 rasterio python_dotenv aiogram==2.25.1 diff --git a/docker/requirements_bot.txt b/docker/requirements_bot.txt index 9d9d6720..572bb301 100644 --- a/docker/requirements_bot.txt +++ b/docker/requirements_bot.txt @@ -1,5 +1,5 @@ opencv-python -osmnx +osmnx<2.0.0 rasterio python_dotenv aiogram==2.25.1 diff --git a/docker/requirements_webui.txt b/docker/requirements_webui.txt index dbfbe2d6..c5cef48c 100644 --- a/docker/requirements_webui.txt +++ b/docker/requirements_webui.txt @@ -1,5 +1,5 @@ opencv-python -osmnx +osmnx<2.0.0 rasterio streamlit tqdm \ No newline at end of file diff --git a/maps4fs/__init__.py b/maps4fs/__init__.py index 82c7c4ba..74157001 100644 --- a/maps4fs/__init__.py +++ b/maps4fs/__init__.py @@ -1,3 +1,4 @@ # pylint: disable=missing-module-docstring +from maps4fs.generator.game import Game from maps4fs.generator.map import Map from maps4fs.logger import Logger diff --git a/maps4fs/generator/component.py b/maps4fs/generator/component.py index 3bea9d21..cccbd594 100644 --- a/maps4fs/generator/component.py +++ b/maps4fs/generator/component.py @@ -1,6 +1,11 @@ """This module contains the base class for all map generation components.""" -from typing import Any +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from maps4fs.generator.game import Game # pylint: disable=R0801, R0903 @@ -17,16 +22,29 @@ class Component: def __init__( self, + game: Game, coordinates: tuple[float, float], distance: int, map_directory: str, logger: Any = None, - **kwargs, # pylint: disable=W0613 + **kwargs, # pylint: disable=W0613, R0913, R0917 ): + self.game = game self.coordinates = coordinates self.distance = distance self.map_directory = map_directory self.logger = logger + self.kwargs = kwargs + + self.preprocess() + + def preprocess(self) -> None: + """Prepares the component for processing. Must be implemented in the child class. + + Raises: + NotImplementedError: If the method is not implemented in the child class. + """ + raise NotImplementedError def process(self) -> None: """Launches the component processing. Must be implemented in the child class. diff --git a/maps4fs/generator/config.py b/maps4fs/generator/config.py index 3a87f0fa..06e9fe4f 100644 --- a/maps4fs/generator/config.py +++ b/maps4fs/generator/config.py @@ -1,7 +1,8 @@ """This module contains the Config class for map settings and configuration.""" +from __future__ import annotations + import os -from typing import Any from xml.etree import ElementTree as ET from maps4fs.generator.component import Component @@ -19,16 +20,9 @@ class Config(Component): info, warning. If not provided, default logging will be used. """ - def __init__( - self, - coordinates: tuple[float, float], - distance: int, - map_directory: str, - logger: Any = None, - **kwargs, - ): - super().__init__(coordinates, distance, map_directory, logger) - self._map_xml_path = os.path.join(self.map_directory, "maps", "map", "map.xml") + def preprocess(self) -> None: + self._map_xml_path = self.game.map_xml_path(self.map_directory) + self.logger.debug(f"Map XML path: {self._map_xml_path}") def process(self): self._set_map_size() diff --git a/maps4fs/generator/dem.py b/maps4fs/generator/dem.py index 8aa9e25b..99f30fa7 100644 --- a/maps4fs/generator/dem.py +++ b/maps4fs/generator/dem.py @@ -4,7 +4,6 @@ import math import os import shutil -from typing import Any import cv2 import numpy as np @@ -29,25 +28,17 @@ class DEM(Component): info, warning. If not provided, default logging will be used. """ - def __init__( - self, - coordinates: tuple[float, float], - distance: int, - map_directory: str, - logger: Any = None, - **kwargs, - ): - super().__init__(coordinates, distance, map_directory, logger) - self._dem_path = os.path.join(self.map_directory, "maps", "map", "data", "map_dem.png") + def preprocess(self) -> None: + self._blur_seed: int = self.kwargs.get("blur_seed") or 5 + self._max_height: int = self.kwargs.get("max_height") or 200 + + self._dem_path = self.game.dem_file_path(self.map_directory) self.temp_dir = "temp" self.hgt_dir = os.path.join(self.temp_dir, "hgt") self.gz_dir = os.path.join(self.temp_dir, "gz") os.makedirs(self.hgt_dir, exist_ok=True) os.makedirs(self.gz_dir, exist_ok=True) - self._blur_seed: int = kwargs.get("blur_seed") or 5 - self._max_height: int = kwargs.get("max_height") or 200 - # pylint: disable=no-member def process(self) -> None: """Reads SRTM file, crops it to map size, normalizes and blurs it, @@ -59,7 +50,14 @@ def process(self) -> None: f"Processing DEM. North: {north}, south: {south}, east: {east}, west: {west}." ) - dem_output_resolution = (self.distance + 1, self.distance + 1) + dem_output_size = self.distance * self.game.dem_multipliyer + 1 + self.logger.debug( + "DEM multiplier is %s, DEM output size is %s.", + self.game.dem_multipliyer, + dem_output_size, + ) + dem_output_resolution = (dem_output_size, dem_output_size) + self.logger.debug("DEM output resolution: %s.", dem_output_resolution) tile_path = self._srtm_tile() if not tile_path: diff --git a/maps4fs/generator/game.py b/maps4fs/generator/game.py new file mode 100644 index 00000000..df7fefa1 --- /dev/null +++ b/maps4fs/generator/game.py @@ -0,0 +1,140 @@ +"""This module contains the Game class and its subclasses. Game class is used to define +different versions of the game for which the map is generated. Each game has its own map +template file and specific settings for map generation.""" + +from __future__ import annotations + +import os + +from maps4fs.generator.config import Config +from maps4fs.generator.dem import DEM +from maps4fs.generator.texture import Texture + +working_directory = os.getcwd() + + +class Game: + """Class used to define different versions of the game for which the map is generated. + + Arguments: + map_template_path (str, optional): Path to the map template file. Defaults to None. + + Attributes and Properties: + code (str): The code of the game. + components (list[Type[Component]]): List of components used for map generation. + map_template_path (str): Path to the map template file. + + Public Methods: + from_code(cls, code: str) -> Game: Returns the game instance based on the game code. + """ + + code: str | None = None + dem_multipliyer: int = 1 + _map_template_path: str | None = None + _texture_schema: str | None = None + + components = [Config, Texture, DEM] + + def __init__(self, map_template_path: str | None = None): + if map_template_path: + self._map_template_path = map_template_path + + def map_xml_path(self, map_directory: str) -> str: + """Returns the path to the map.xml file. + + Arguments: + map_directory (str): The path to the map directory. + + Returns: + str: The path to the map.xml file. + """ + return os.path.join(map_directory, "maps", "map", "map.xml") + + @classmethod + def from_code(cls, code: str) -> Game: + """Returns the game instance based on the game code. + + Arguments: + code (str): The code of the game. + + Returns: + Game: The game instance. + """ + for game in cls.__subclasses__(): + if game.code and game.code.lower() == code.lower(): + return game() + raise ValueError(f"Game with code {code} not found.") + + @property + def template_path(self) -> str: + """Returns the path to the map template file. + + Raises: + ValueError: If the map template path is not set. + + Returns: + str: The path to the map template file.""" + if not self._map_template_path: + raise ValueError("Map template path not set.") + return self._map_template_path + + @property + def texture_schema(self) -> str: + """Returns the path to the texture layers schema file. + + Raises: + ValueError: If the texture layers schema path is not set. + + Returns: + str: The path to the texture layers schema file.""" + if not self._texture_schema: + raise ValueError("Texture layers schema path not set.") + return self._texture_schema + + def dem_file_path(self, map_directory: str) -> str: + """Returns the path to the DEM file. + + Arguments: + map_directory (str): The path to the map directory. + + Returns: + str: The path to the DEM file. + """ + raise NotImplementedError + + +class FS22(Game): + """Class used to define the game version FS22.""" + + code = "FS22" + _map_template_path = os.path.join(working_directory, "data", "fs22-map-template.zip") + _texture_schema = os.path.join(working_directory, "data", "fs22-texture-schema.json") + + def dem_file_path(self, map_directory: str) -> str: + """Returns the path to the DEM file. + + Arguments: + map_directory (str): The path to the map directory. + + Returns: + str: The path to the DEM file.""" + return os.path.join(map_directory, "maps", "map", "data", "map_dem.png") + + +class FS25(Game): + """Class used to define the game version FS25.""" + + code = "FS25" + dem_multipliyer: int = 2 + _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") + + def dem_file_path(self, map_directory: str) -> str: + """Returns the path to the DEM file. + + Arguments: + map_directory (str): The path to the map directory. + + Returns: + str: The path to the DEM file.""" + return os.path.join(map_directory, "maps", "map", "data", "dem.png") diff --git a/maps4fs/generator/map.py b/maps4fs/generator/map.py index e22e71ff..2e428ae4 100644 --- a/maps4fs/generator/map.py +++ b/maps4fs/generator/map.py @@ -6,39 +6,35 @@ from tqdm import tqdm -from maps4fs.generator.component import Component -from maps4fs.generator.config import Config -from maps4fs.generator.dem import DEM -from maps4fs.generator.texture import Texture +from maps4fs.generator.game import Game from maps4fs.logger import Logger -BaseComponents = [Config, Texture, DEM] - # pylint: disable=R0913 class Map: """Class used to generate map using all components. Args: + game (Type[Game]): Game for which the map is generated. coordinates (tuple[float, float]): Coordinates of the center of the map. distance (int): Distance from the center of the map. map_directory (str): Path to the directory where map files will be stored. blur_seed (int): Seed used for blur effect. max_height (int): Maximum height of the map. - map_template (str | None): Path to the map template. If not provided, default will be used. logger (Any): Logger instance """ def __init__( # pylint: disable=R0917 self, + game: Game, coordinates: tuple[float, float], distance: int, map_directory: str, blur_seed: int, max_height: int, - map_template: str | None = None, logger: Any = None, ): + self.game = game self.coordinates = coordinates self.distance = distance self.map_directory = map_directory @@ -46,17 +42,16 @@ def __init__( # pylint: disable=R0917 if not logger: logger = Logger(__name__, to_stdout=True, to_file=False) self.logger = logger - self.components: list[Component] = [] + self.logger.debug("Game was set to %s", game.code) os.makedirs(self.map_directory, exist_ok=True) - if map_template: - shutil.unpack_archive(map_template, self.map_directory) + self.logger.debug("Map directory created: %s", self.map_directory) + + try: + shutil.unpack_archive(game.template_path, self.map_directory) self.logger.info("Map template unpacked to %s", self.map_directory) - else: - self.logger.warning( - "Map template not provided, if directory does not contain required files, " - "it may not work properly in Giants Editor." - ) + except Exception as e: + raise RuntimeError(f"Can not unpack map template due to error: {e}") from e # Blur seed should be positive and odd. if blur_seed <= 0: @@ -64,27 +59,22 @@ def __init__( # pylint: disable=R0917 if blur_seed % 2 == 0: blur_seed += 1 - self._add_components(blur_seed, max_height) - - def _add_components(self, blur_seed: int, max_height: int) -> None: - self.logger.debug("Starting adding components...") - for component in BaseComponents: - active_component = component( - self.coordinates, - self.distance, - self.map_directory, - self.logger, - blur_seed=blur_seed, - max_height=max_height, - ) - setattr(self, component.__name__.lower(), active_component) - self.components.append(active_component) - self.logger.debug("Added %s components.", len(self.components)) + self.blur_seed = blur_seed + self.max_height = max_height def generate(self) -> None: """Launch map generation using all components.""" - with tqdm(total=len(self.components), desc="Generating map...") as pbar: - for component in self.components: + with tqdm(total=len(self.game.components), desc="Generating map...") as pbar: + for game_component in self.game.components: + component = game_component( + self.game, + self.coordinates, + self.distance, + self.map_directory, + self.logger, + blur_seed=self.blur_seed, + max_height=self.max_height, + ) try: component.process() except Exception as e: # pylint: disable=W0718 @@ -94,6 +84,8 @@ def generate(self) -> None: e, ) raise e + setattr(self, game_component.__name__.lower(), component) + pbar.update(1) def previews(self) -> list[str]: diff --git a/maps4fs/generator/texture.py b/maps4fs/generator/texture.py index f1bf25aa..55a5c45c 100644 --- a/maps4fs/generator/texture.py +++ b/maps4fs/generator/texture.py @@ -1,10 +1,11 @@ """Module with Texture class for generating textures for the map using OSM data.""" +from __future__ import annotations + import json import os -import re import warnings -from typing import Any, Callable, Generator, Optional +from typing import Callable, Generator, Optional import cv2 import numpy as np @@ -16,26 +17,131 @@ from maps4fs.generator.component import Component # region constants -TEXTURES = { - "animalMud": 4, - "asphalt": 4, - "cobbleStone": 4, - "concrete": 4, - "concreteRubble": 4, - "concreteTiles": 4, - "dirt": 4, - "dirtDark": 2, - "forestGround": 4, - "forestGroundLeaves": 4, - "grass": 4, - "grassDirt": 4, - "gravel": 4, - "groundBricks": 4, - "mountainRock": 4, - "mountainRockDark": 4, - "riverMud": 4, - "waterPuddle": 0, -} +# texture = { +# "name": "concrete", +# "count": 4, +# "tags": {"building": True}, +# "width": 8, +# "color": (130, 130, 130), +# } + +# textures = [ +# { +# "name": "animalMud", +# "count": 4, +# }, +# { +# "name": "asphalt", +# "count": 4, +# "tags": {"highway": ["motorway", "trunk", "primary"]}, +# "width": 8, +# "color": (70, 70, 70), +# }, +# { +# "name": "cobbleStone", +# "count": 4, +# }, +# { +# "name": "concrete", +# "count": 4, +# "tags": {"building": True}, +# "width": 8, +# "color": (130, 130, 130), +# }, +# { +# "name": "concreteRubble", +# "count": 4, +# }, +# { +# "name": "concreteTiles", +# "count": 4, +# }, +# { +# "name": "dirt", +# "count": 4, +# }, +# { +# "name": "dirtDark", +# "count": 2, +# "tags": {"highway": ["unclassified", "residential", "track"]}, +# "width": 2, +# "color": (33, 67, 101), +# }, +# { +# "name": "forestGround", +# "count": 4, +# "tags": {"landuse": "farmland"}, +# "color": (47, 107, 85), +# }, +# { +# "name": "forestGroundLeaves", +# "count": 4, +# }, +# { +# "name": "grass", +# "count": 4, +# "tags": {"natural": "grassland"}, +# "color": (34, 255, 34), +# }, +# { +# "name": "grassDirt", +# "count": 4, +# "tags": {"natural": ["wood", "tree_row"]}, +# "width": 2, +# "color": (0, 252, 124), +# }, +# { +# "name": "gravel", +# "count": 4, +# "tags": {"highway": ["secondary", "tertiary", "road"]}, +# "width": 4, +# "color": (140, 180, 210), +# }, +# { +# "name": "groundBricks", +# "count": 4, +# }, +# { +# "name": "mountainRock", +# "count": 4, +# }, +# { +# "name": "mountainRockDark", +# "count": 4, +# }, +# { +# "name": "riverMud", +# "count": 4, +# }, +# { +# "name": "waterPuddle", +# "count": 0, +# "tags": {"natural": "water", "waterway": True}, +# "width": 10, +# "color": (255, 20, 20), +# }, +# ] + +# TEXTURES = { +# ? "animalMud": 4, +# ? "asphalt": 4, +# ? "cobbleStone": 4, +# ? "concrete": 4, +# "concreteRubble": 4, +# "concreteTiles": 4, +# "dirt": 4, +# "dirtDark": 2, +# "forestGround": 4, +# "forestGroundLeaves": 4, +# "grass": 4, +# "grassDirt": 4, +# "gravel": 4, +# "groundBricks": 4, +# "mountainRock": 4, +# "mountainRockDark": 4, +# "riverMud": 4, +# "waterPuddle": 0, +# } # endregion @@ -71,57 +177,72 @@ class Layer: # pylint: disable=R0913 def __init__( # pylint: disable=R0917 self, - weights_dir: str, name: str, - tags: dict[str, str | list[str] | bool], + count: int, + tags: dict[str, str | list[str] | bool] | None = None, width: int | None = None, - color: tuple[int, int, int] | None = None, + color: tuple[int, int, int] | list[int] | None = None, ): - self.weights_dir = weights_dir self.name = name + self.count = count self.tags = tags self.width = width self.color = color if color else (255, 255, 255) - self._get_paths() - def _get_paths(self): - """Gets paths to textures of the layer. + def to_json(self) -> dict[str, str | list[str] | bool]: # type: ignore + """Returns dictionary with layer data. + + Returns: + dict: Dictionary with layer data.""" + data = { + "name": self.name, + "count": self.count, + "tags": self.tags, + "width": self.width, + "color": list(self.color), + } + + data = {k: v for k, v in data.items() if v is not None} + return data # type: ignore + + @classmethod + def from_json(cls, data: dict[str, str | list[str] | bool]) -> Texture.Layer: + """Creates a new instance of the class from dictionary. - Raises: - FileNotFoundError: If texture is not found. + Args: + data (dict[str, str | list[str] | bool]): Dictionary with layer data. + + Returns: + Layer: New instance of the class. """ - if self.name == "waterPuddle": - self.paths = [os.path.join(self.weights_dir, "waterPuddle_weight.png")] - return - weight_files = [ - os.path.join(self.weights_dir, f) - for f in os.listdir(self.weights_dir) - if f.endswith("_weight.png") - ] - pattern = re.compile(rf"{self.name}\d{{2}}_weight") - paths = [path for path in weight_files if pattern.search(path)] - if not paths: - raise FileNotFoundError(f"Texture not found: {self.name}") - self.paths = paths - - @property - def path(self) -> str: + return cls(**data) # type: ignore + + def path(self, weights_directory: str) -> str: """Returns path to the first texture of the layer. + Arguments: + weights_directory (str): Path to the directory with weights. + Returns: str: Path to the texture. """ - return self.paths[0] + if self.name == "waterPuddle": + return os.path.join(weights_directory, "waterPuddle_weight.png") + return os.path.join(weights_directory, f"{self.name}01_weight.png") + + def preprocess(self) -> None: + if not os.path.isfile(self.game.texture_schema): + raise FileNotFoundError(f"Texture layers schema not found: {self.game.texture_schema}") + + try: + with open(self.game.texture_schema, "r", encoding="utf-8") as f: + layers_schema = json.load(f) + except json.JSONDecodeError as e: + raise ValueError(f"Error loading texture layers schema: {e}") from e + + self.layers = [self.Layer.from_json(layer) for layer in layers_schema] + self.logger.info("Loaded %s layers.", len(self.layers)) - def __init__( - self, - coordinates: tuple[float, float], - distance: int, - map_directory: str, - logger: Any = None, - **kwargs, - ): - super().__init__(coordinates, distance, map_directory, logger) self._weights_dir = os.path.join(self.map_directory, "maps", "map", "data") self._bbox = ox.utils_geo.bbox_from_point(self.coordinates, dist=self.distance) self.info_save_path = os.path.join(self.map_directory, "generation_info.json") @@ -194,10 +315,11 @@ def info_sequence(self) -> None: self.logger.info("Generation info saved to %s.", self.info_save_path) def _prepare_weights(self): - self.logger.debug("Starting preparing weights...") - for texture_name, layer_numbers in TEXTURES.items(): - self._generate_weights(texture_name, layer_numbers) - self.logger.debug("Prepared weights for %s textures.", len(TEXTURES)) + self.logger.debug("Starting preparing weights from %s layers.", len(self.layers)) + + for layer in self.layers: + self._generate_weights(layer.name, layer.count) + self.logger.debug("Prepared weights for %s layers.", len(self.layers)) def _generate_weights(self, texture_name: str, layer_numbers: int) -> None: """Generates weight files for textures. Each file is a numpy array of zeros and @@ -228,69 +350,28 @@ def layers(self) -> list[Layer]: Returns: list[Layer]: List of layers. """ - asphalt = self.Layer( - self._weights_dir, - "asphalt", - {"highway": ["motorway", "trunk", "primary"]}, - width=8, - color=(70, 70, 70), - ) - concrete = self.Layer( - self._weights_dir, "concrete", {"building": True}, width=8, color=(130, 130, 130) - ) - dirt_dark = self.Layer( - self._weights_dir, - "dirtDark", - {"highway": ["unclassified", "residential", "track"]}, - width=2, - color=(33, 67, 101), - ) - grass_dirt = self.Layer( - self._weights_dir, - "grassDirt", - {"natural": ["wood", "tree_row"]}, - width=2, - color=(0, 252, 124), - ) - grass = self.Layer( - self._weights_dir, "grass", {"natural": "grassland"}, color=(34, 255, 34) - ) - forest_ground = self.Layer( - self._weights_dir, "forestGround", {"landuse": "farmland"}, color=(47, 107, 85) - ) - gravel = self.Layer( - self._weights_dir, - "gravel", - {"highway": ["secondary", "tertiary", "road"]}, - width=4, - color=(140, 180, 210), - ) - water_puddle = self.Layer( - self._weights_dir, - "waterPuddle", - {"natural": "water", "waterway": True}, - width=10, - color=(255, 20, 20), - ) - return [ - asphalt, - concrete, - dirt_dark, - forest_ground, - grass, - grass_dirt, - gravel, - water_puddle, - ] + return self._layers + + @layers.setter + def layers(self, layers: list[Layer]) -> None: + """Sets list of layers with textures and tags. + + Args: + layers (list[Layer]): List of layers. + """ + self._layers = layers # pylint: disable=no-member def draw(self) -> None: """Iterates over layers and fills them with polygons from OSM data.""" for layer in self.layers: - img = cv2.imread(layer.path, cv2.IMREAD_UNCHANGED) - for polygon in self.polygons(layer.tags, layer.width): + layer_path = layer.path(self._weights_dir) + self.logger.debug("Drawing layer %s.", layer_path) + + img = cv2.imread(layer_path, cv2.IMREAD_UNCHANGED) + for polygon in self.polygons(layer.tags, layer.width): # type: ignore cv2.fillPoly(img, [polygon], color=255) # type: ignore - cv2.imwrite(layer.path, img) + cv2.imwrite(layer_path, img) self.logger.debug("Texture %s saved.", layer.path) def get_relative_x(self, x: float) -> int: @@ -433,7 +514,9 @@ def _osm_preview(self) -> str: """ preview_size = (2048, 2048) images = [ - cv2.resize(cv2.imread(layer.path, cv2.IMREAD_UNCHANGED), preview_size) + cv2.resize( + cv2.imread(layer.path(self._weights_dir), cv2.IMREAD_UNCHANGED), preview_size + ) for layer in self.layers ] colors = [layer.color for layer in self.layers] diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..cefa97cf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,32 @@ +[build-system] +requires = ["setuptools>=42", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "maps4fs" +version = "0.6.0" +description = "Generate map templates for Farming Simulator from real places." +authors = [{name = "iwatkot", email = "iwatkot@gmail.com"}] +license = {text = "MIT License"} +readme = "README.md" +keywords = ["farmingsimulator", "fs", "farmingsimulator22", "farmingsimulator25", "fs22", "fs25"] +classifiers = [ + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "License :: OSI Approved :: MIT License", + "Operating System :: OS Independent", +] +dependencies = [ + "pydantic>=2.0.0", + "requests>=2.0.0", + "httpx>=0.20.0", +] + +[project.urls] +Homepage = "https://github.com/iwatkot/maps4fs" +Repository = "https://github.com/iwatkot/maps4fs" + +[tool.setuptools.packages.find] +where = ["."] +include = ["maps4fs*"] +exclude = ["dev*", "bot*", "*data", "*docker", "*webui"] diff --git a/tests/test_generator.py b/tests/test_generator.py index ceee1756..150d57b3 100644 --- a/tests/test_generator.py +++ b/tests/test_generator.py @@ -1,3 +1,4 @@ +import json import os import shutil from random import choice, randint @@ -6,12 +7,12 @@ import cv2 from maps4fs import Map -from maps4fs.generator.texture import TEXTURES +from maps4fs.generator.game import Game + +# from maps4fs.generator.texture import TEXTURES working_directory = os.getcwd() -map_template = os.path.join(working_directory, "data/map-template.zip") -if not os.path.isfile(map_template): - raise FileNotFoundError(f"Map template not found at {map_template}") + base_directory = os.path.join(working_directory, "tests/data") if os.path.isdir(base_directory): shutil.rmtree(base_directory) @@ -26,6 +27,8 @@ (35.25541295723034, 139.04857855524995), ] +game_code_cases = ["FS22"] + def random_distance() -> int: """Return random distance. @@ -67,52 +70,72 @@ def map_directory() -> str: return directory +def load_textures_schema(json_path: str) -> dict: + """Load textures schema from JSON file. + + Args: + json_path (str): Path to the JSON file. + + Returns: + dict: Loaded JSON file. + """ + with open(json_path, "r") as file: + return json.load(file) + + def test_map(): """Test Map generation for different cases.""" - for coordinates in coordinates_cases: - distance = random_distance() - blur_seed = random_blur_seed() - max_height = random_max_height() - directory = map_directory() - - map = Map( - coordinates=coordinates, - distance=distance, - map_directory=directory, - blur_seed=blur_seed, - max_height=max_height, - map_template=map_template, - ) - - map.generate() - - textures_directory = os.path.join(directory, "maps/map/data") - for texture_name, numer_of_layers in TEXTURES.items(): - if numer_of_layers == 0: - continue - for idx in range(1, numer_of_layers + 1): - texture_path = os.path.join( - textures_directory, f"{texture_name}{str(idx).zfill(2)}_weight.png" - ) - assert os.path.isfile(texture_path), f"Texture not found: {texture_path}" - img = cv2.imread(texture_path) - assert img is not None, f"Texture could not be read: {texture_path}" - assert img.shape == ( - distance * 2, - distance * 2, - 3, - ), f"Texture shape mismatch: {img.shape} != {(distance * 2, distance * 2, 3)}" - assert img.dtype == "uint8", f"Texture dtype mismatch: {img.dtype} != uint8" - - dem_file = os.path.join(textures_directory, "map_dem.png") - assert os.path.isfile(dem_file), f"DEM file not found: {dem_file}" - img = cv2.imread(dem_file, cv2.IMREAD_UNCHANGED) - assert img is not None, f"DEM could not be read: {dem_file}" - assert img.shape == ( - distance + 1, - distance + 1, - ), f"DEM shape mismatch: {img.shape} != {(distance + 1, distance + 1)}" - assert img.dtype == "uint16", f"DEM dtype mismatch: {img.dtype} != uint16" + for game_code in game_code_cases: + game = Game.from_code(game_code) + for coordinates in coordinates_cases: + distance = random_distance() + blur_seed = random_blur_seed() + max_height = random_max_height() + directory = map_directory() + + map = Map( + game=game, + coordinates=coordinates, + distance=distance, + map_directory=directory, + blur_seed=blur_seed, + max_height=max_height, + ) + + map.generate() + + layers_schema = load_textures_schema(game.texture_schema) + + textures_directory = os.path.join(directory, "maps/map/data") + for texture in layers_schema: + texture_name = texture["name"] + numer_of_layers = texture["count"] + + if numer_of_layers == 0: + continue + for idx in range(1, numer_of_layers + 1): + texture_path = os.path.join( + textures_directory, f"{texture_name}{str(idx).zfill(2)}_weight.png" + ) + assert os.path.isfile(texture_path), f"Texture not found: {texture_path}" + img = cv2.imread(texture_path) + assert img is not None, f"Texture could not be read: {texture_path}" + assert img.shape == ( + distance * 2, + distance * 2, + 3, + ), f"Texture shape mismatch: {img.shape} != {(distance * 2, distance * 2, 3)}" + assert img.dtype == "uint8", f"Texture dtype mismatch: {img.dtype} != uint8" + + dem_file = os.path.join(textures_directory, "map_dem.png") + assert os.path.isfile(dem_file), f"DEM file not found: {dem_file}" + img = cv2.imread(dem_file, cv2.IMREAD_UNCHANGED) + assert img is not None, f"DEM could not be read: {dem_file}" + assert img.shape == ( + distance + 1, + distance + 1, + ), f"DEM shape mismatch: {img.shape} != {(distance + 1, distance + 1)}" + assert img.dtype == "uint16", f"DEM dtype mismatch: {img.dtype} != uint16" def test_map_preview(): @@ -122,14 +145,17 @@ def test_map_preview(): blur_seed = random_blur_seed() max_height = random_max_height() + game_code = choice(game_code_cases) + game = Game.from_code(game_code) + directory = map_directory() map = Map( + game=game, coordinates=case, distance=distance, map_directory=directory, blur_seed=blur_seed, max_height=max_height, - map_template=map_template, ) map.generate() previews_paths = map.previews() @@ -152,14 +178,17 @@ def test_map_pack(): blur_seed = random_blur_seed() max_height = random_max_height() + game_code = choice(game_code_cases) + game = Game.from_code(game_code) + directory = map_directory() map = Map( + game=game, coordinates=case, distance=distance, map_directory=directory, blur_seed=blur_seed, max_height=max_height, - map_template=map_template, ) map.generate() archive_name = os.path.join(base_directory, "archive") diff --git a/webui/webui.py b/webui/webui.py index d4044d0d..5cb25cb0 100644 --- a/webui/webui.py +++ b/webui/webui.py @@ -7,7 +7,6 @@ working_directory = os.getcwd() archives_directory = os.path.join(working_directory, "archives") -map_template = os.path.join(working_directory, "data", "map-template.zip") maps_directory = os.path.join(working_directory, "maps") os.makedirs(archives_directory, exist_ok=True) os.makedirs(maps_directory, exist_ok=True) @@ -19,6 +18,8 @@ def launch_process(): + game = mfs.Game.from_code(game_code_input) + try: lat, lon = map(float, lat_lon_input.split(",")) except ValueError: @@ -43,19 +44,19 @@ def launch_process(): st.error("Invalid blur seed!") return - session_name = f"{time()}" + session_name = f"{int(time())}" st.success("Started map generation...") map_directory = os.path.join(maps_directory, session_name) os.makedirs(map_directory, exist_ok=True) mp = mfs.Map( + game, coordinates, distance, map_directory, blur_seed=blur_seed, max_height=max_height, - map_template=map_template, logger=logger, ) mp.generate() @@ -70,6 +71,14 @@ def launch_process(): # UI Elements +st.write("Select the game for which you want to generate the map:") +game_code_input = st.selectbox( + "Game", + options=["FS22"], # TODO: Return "FS25" when the Giants Editor v10 will be released. + key="game_code", + label_visibility="collapsed", +) + st.write("Enter latitude and longitude of the center point of the map:") lat_lon_input = st.text_input( "Latitude and Longitude", "45.2602, 19.8086", key="lat_lon", label_visibility="collapsed"