diff --git a/data/fs25-map-template.zip b/data/fs25-map-template.zip new file mode 100644 index 00000000..99ede4ba Binary files /dev/null and b/data/fs25-map-template.zip differ diff --git a/data/fs25-texture-schema.json b/data/fs25-texture-schema.json new file mode 100644 index 00000000..15ec2f6a --- /dev/null +++ b/data/fs25-texture-schema.json @@ -0,0 +1,197 @@ +[ + { + "name": "asphalt", + "count": 2, + "tags": { "highway": ["motorway", "trunk", "primary"] }, + "width": 8, + "color": [70, 70, 70] + }, + { + "name": "asphaltCracks", + "count": 2 + }, + { + "name": "asphaltDirt", + "count": 2 + }, + { + "name": "asphaltDusty", + "count": 2 + }, + { + "name": "asphaltGravel", + "count": 2 + }, + { + "name": "asphaltTwigs", + "count": 2 + }, + { + "name": "concrete", + "count": 2, + "tags": { "building": true }, + "width": 8, + "color": [130, 130, 130] + }, + { + "name": "concreteGravelSand", + "count": 2 + }, + { + "name": "concretePebbles", + "count": 2 + }, + { + "name": "concreteShattered", + "count": 2 + }, + { + "name": "forestGrass", + "count": 2 + }, + { + "name": "forestLeaves", + "count": 2 + }, + { + "name": "forestNeedels", + "count": 2 + }, + { + "name": "forestRockRoots", + "count": 2, + "exclude_weight": true + }, + { + "name": "grass", + "count": 2, + "tags": { "natural": "grassland" }, + "color": [34, 255, 34] + }, + { + "name": "grassClovers", + "count": 2 + }, + { + "name": "grassCut", + "count": 2 + }, + { + "name": "grassDirtPatchy", + "count": 2, + "tags": { "natural": ["wood", "tree_row"] }, + "width": 2, + "color": [0, 252, 124] + }, + { + "name": "grassDirtPatchyDry", + "count": 2 + }, + { + "name": "grassDirtStones", + "count": 2 + }, + { + "name": "grassFreshMiddle", + "count": 2 + }, + { + "name": "grassFreshShort", + "count": 2 + }, + { + "name": "grassMoss", + "count": 2 + }, + { + "name": "gravel", + "count": 2, + "tags": { "highway": ["secondary", "tertiary", "road"] }, + "width": 4, + "color": [140, 180, 210] + }, + { + "name": "gravelDirtMoss", + "count": 2 + }, + { + "name": "gravelPebblesMoss", + "count": 2 + }, + { + "name": "gravelPebblesMossPatchy", + "count": 2 + }, + { + "name": "gravelSmall", + "count": 2 + }, + { + "name": "mudDark", + "count": 2, + "tags": { "highway": ["unclassified", "residential", "track"] }, + "width": 2, + "color": [33, 67, 101] + }, + { + "name": "mudDarkGrassPatchy", + "count": 2 + }, + { + "name": "mudDarkMossPatchy", + "count": 2 + }, + { + "name": "mudLeaves", + "count": 2 + }, + { + "name": "mudLight", + "count": 2 + }, + { + "name": "mudPebbles", + "count": 2 + }, + { + "name": "mudPebblesLight", + "count": 2 + }, + { + "name": "mudTracks", + "count": 2 + }, + { + "name": "pebblesForestGround", + "count": 2 + }, + { + "name": "rock", + "count": 2 + }, + { + "name": "rockFloorTiles", + "count": 2 + }, + { + "name": "rockFloorTilesPattern", + "count": 2 + }, + { + "name": "rockForest", + "count": 2 + }, + { + "name": "rockyForestGround", + "count": 2, + "tags": { "landuse": "farmland" }, + "color": [47, 107, 85] + }, + { + "name": "sand", + "count": 2, + "tags": { "natural": "water", "waterway": true }, + "width": 10, + "color": [255, 20, 20] + } +] diff --git a/maps4fs/generator/dem.py b/maps4fs/generator/dem.py index c5193780..ead526d1 100644 --- a/maps4fs/generator/dem.py +++ b/maps4fs/generator/dem.py @@ -129,6 +129,12 @@ def process(self) -> None: cv2.imwrite(self._dem_path, resampled_data) self.logger.debug("DEM data was saved to %s.", self._dem_path) + if self.game.additional_dem_name is not None: + dem_directory = os.path.dirname(self._dem_path) + additional_dem_path = os.path.join(dem_directory, self.game.additional_dem_name) + shutil.copyfile(self._dem_path, additional_dem_path) + self.logger.debug("Additional DEM data was copied to %s.", additional_dem_path) + def _tile_info(self, lat: float, lon: float) -> tuple[str, str]: """Returns latitude band and tile name for SRTM tile from coordinates. diff --git a/maps4fs/generator/game.py b/maps4fs/generator/game.py index df7fefa1..2d5071fe 100644 --- a/maps4fs/generator/game.py +++ b/maps4fs/generator/game.py @@ -30,6 +30,7 @@ class Game: code: str | None = None dem_multipliyer: int = 1 + _additional_dem_name: str | None = None _map_template_path: str | None = None _texture_schema: str | None = None @@ -102,6 +103,24 @@ def dem_file_path(self, map_directory: str) -> str: """ raise NotImplementedError + def weights_dir_path(self, map_directory: str) -> str: + """Returns the path to the weights directory. + + Arguments: + map_directory (str): The path to the map directory. + + Returns: + str: The path to the weights directory.""" + raise NotImplementedError + + @property + def additional_dem_name(self) -> str | None: + """Returns the name of the additional DEM file. + + Returns: + str | None: The name of the additional DEM file.""" + return self._additional_dem_name + class FS22(Game): """Class used to define the game version FS22.""" @@ -120,12 +139,23 @@ def dem_file_path(self, map_directory: str) -> str: str: The path to the DEM file.""" return os.path.join(map_directory, "maps", "map", "data", "map_dem.png") + def weights_dir_path(self, map_directory: str) -> str: + """Returns the path to the weights directory. + + Arguments: + map_directory (str): The path to the map directory. + + Returns: + str: The path to the weights directory.""" + return os.path.join(map_directory, "maps", "map", "data") + class FS25(Game): """Class used to define the game version FS25.""" code = "FS25" dem_multipliyer: int = 2 + _additional_dem_name = "unprocessedHeightMap.png" _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") @@ -137,4 +167,25 @@ def dem_file_path(self, map_directory: str) -> str: Returns: str: The path to the DEM file.""" - return os.path.join(map_directory, "maps", "map", "data", "dem.png") + return os.path.join(map_directory, "mapUS", "data", "dem.png") + + 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, "mapUS", "mapUS.xml") + + def weights_dir_path(self, map_directory: str) -> str: + """Returns the path to the weights directory. + + Arguments: + map_directory (str): The path to the map directory. + + Returns: + str: The path to the weights directory.""" + return os.path.join(map_directory, "mapUS", "data") diff --git a/maps4fs/generator/texture.py b/maps4fs/generator/texture.py index dff6c38d..d0a138c7 100644 --- a/maps4fs/generator/texture.py +++ b/maps4fs/generator/texture.py @@ -56,12 +56,14 @@ def __init__( # pylint: disable=R0917 tags: dict[str, str | list[str] | bool] | None = None, width: int | None = None, color: tuple[int, int, int] | list[int] | None = None, + exclude_weight: bool = False, ): self.name = name self.count = count self.tags = tags self.width = width self.color = color if color else (255, 255, 255) + self.exclude_weight = exclude_weight def to_json(self) -> dict[str, str | list[str] | bool]: # type: ignore """Returns dictionary with layer data. @@ -74,6 +76,7 @@ def to_json(self) -> dict[str, str | list[str] | bool]: # type: ignore "tags": self.tags, "width": self.width, "color": list(self.color), + "exclude_weight": self.exclude_weight, } data = {k: v for k, v in data.items() if v is not None} @@ -100,9 +103,9 @@ def path(self, weights_directory: str) -> str: Returns: str: Path to the texture. """ - 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") + idx = "01" if self.count > 0 else "" + weight_postfix = "_weight" if not self.exclude_weight else "" + return os.path.join(weights_directory, f"{self.name}{idx}{weight_postfix}.png") def preprocess(self) -> None: if not os.path.isfile(self.game.texture_schema): @@ -117,8 +120,10 @@ def preprocess(self) -> None: self.layers = [self.Layer.from_json(layer) for layer in layers_schema] self.logger.info("Loaded %s layers.", len(self.layers)) - self._weights_dir = os.path.join(self.map_directory, "maps", "map", "data") + self._weights_dir = self.game.weights_dir_path(self.map_directory) + self.logger.debug("Weights directory: %s.", self._weights_dir) self.info_save_path = os.path.join(self.map_directory, "generation_info.json") + self.logger.debug("Generation info save path: %s.", self.info_save_path) def process(self): self._prepare_weights() @@ -192,25 +197,24 @@ def _prepare_weights(self): 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._generate_weights(layer) self.logger.debug("Prepared weights for %s layers.", len(self.layers)) - def _generate_weights(self, texture_name: str, layer_numbers: int) -> None: + def _generate_weights(self, layer: Layer) -> None: """Generates weight files for textures. Each file is a numpy array of zeros and dtype uint8 (0-255). Args: - texture_name (str): Name of the texture. - layer_numbers (int): Number of layers in the texture. + layer (Layer): Layer with textures and tags. """ size = (self.map_height, self.map_width) - postfix = "_weight.png" - if layer_numbers == 0: - filepaths = [os.path.join(self._weights_dir, texture_name + postfix)] + postfix = "_weight.png" if not layer.exclude_weight else ".png" + if layer.count == 0: + filepaths = [os.path.join(self._weights_dir, layer.name + postfix)] else: filepaths = [ - os.path.join(self._weights_dir, texture_name + str(i).zfill(2) + postfix) - for i in range(1, layer_numbers + 1) + os.path.join(self._weights_dir, layer.name + str(i).zfill(2) + postfix) + for i in range(1, layer.count + 1) ] for filepath in filepaths: @@ -403,12 +407,23 @@ def _osm_preview(self) -> str: preview_size, ) - images = [ - cv2.resize( - cv2.imread(layer.path(self._weights_dir), cv2.IMREAD_UNCHANGED), preview_size + images = [] + for layer in self.layers: + self.logger.debug( + "Reading layer %s from %s.", layer.name, layer.path(self._weights_dir) ) - for layer in self.layers - ] + images.append( + cv2.resize( + cv2.imread(layer.path(self._weights_dir), cv2.IMREAD_UNCHANGED), preview_size + ) + ) + + # images = [ + # 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] color_images = [] for img, color in zip(images, colors): diff --git a/tests/tests.py b/tests/tests.py new file mode 100644 index 00000000..6200c4df --- /dev/null +++ b/tests/tests.py @@ -0,0 +1,65 @@ +import json +import os +from collections import defaultdict + +import cv2 + +map_path = "data/FS25_EmptyMap" +inner_directory_name = "mapUS" +data_directory_name = "data" + +inner_directory_path = os.path.join(map_path, inner_directory_name) +data_directory_path = os.path.join(map_path, inner_directory_name, data_directory_name) +print(f"Map path: {map_path}") +print(f"Inner directory path: {inner_directory_path}") +print(f"Data directory path: {data_directory_path}") + +if not os.path.exists(data_directory_path): + raise FileNotFoundError(f"Data directory not found: {data_directory_path}") + +# data_files = os.listdir(data_directory_path) +# # "forestRockRoots02.png", "forestRockRoots01.png" + +# # key - name, value - number of files +# weight_files = defaultdict(lambda: 0) +# for file in data_files: +# if file.endswith("_weight.png"): +# splitted_name = file.split("_weight.png")[0] +# sliced_name = splitted_name[:-2] +# weight_files[sliced_name] += 1 + +# # Sort by key by alphabet +# weight_files = dict(sorted(weight_files.items())) + +# sliced_names_filepath = "sliced.json" +# with open(sliced_names_filepath, "w") as f: +# json.dump(weight_files, f) + +dem_path = os.path.join(data_directory_path, "dem.png") +unprocessed_dem_path = os.path.join(data_directory_path, "unprocessedHeightMap.png") + +# Read both files and compare them + +dem = cv2.imread(dem_path, cv2.IMREAD_UNCHANGED) +unprocessed_dem = cv2.imread(unprocessed_dem_path, cv2.IMREAD_UNCHANGED) + +if dem.shape != unprocessed_dem.shape: + print( + f"Shapes are different. DEM shape: {dem.shape}, Unprocessed DEM shape: {unprocessed_dem.shape}" + ) +else: + print(f"Shapes are equal: {dem.shape}") + +if dem.dtype != unprocessed_dem.dtype: + print( + f"Data types are different. DEM dtype: {dem.dtype}, Unprocessed DEM dtype: {unprocessed_dem.dtype}" + ) +else: + print(f"Data types are equal: {dem.dtype}") + +# Compare pixel by pixel +for i in range(dem.shape[0]): + for j in range(dem.shape[1]): + if dem[i, j] != unprocessed_dem[i, j]: + print(f"Pixel at {i}, {j} is different: {dem[i, j]} != {unprocessed_dem[i, j]}") + break diff --git a/webui/webui.py b/webui/webui.py index 5ef5267d..d8c2fe40 100644 --- a/webui/webui.py +++ b/webui/webui.py @@ -1,5 +1,5 @@ import os -from time import time +from datetime import datetime import config import streamlit as st @@ -30,7 +30,10 @@ def add_widgets(self) -> None: st.write("Select the game for which you want to generate the map:") self.game_code_input = st.selectbox( "Game", - options=["FS22"], # TODO: Return "FS25" when the Giants Editor v10 will be released. + options=[ + "FS25", + "FS22", + ], key="game_code", label_visibility="collapsed", ) @@ -39,7 +42,7 @@ def add_widgets(self) -> None: st.write("Enter latitude and longitude of the center point of the map:") self.lat_lon_input = st.text_input( "Latitude and Longitude", - "45.2856, 20.2374", + "45.26, 19.80", key="lat_lon", label_visibility="collapsed", ) @@ -127,7 +130,8 @@ def add_widgets(self) -> None: def generate_map(self) -> None: # Read game code from the input widget and create a game object. - game = mfs.Game.from_code(self.game_code_input) + game_code = self.game_code_input + game = mfs.Game.from_code(game_code) try: # Read latitude and longitude from the input widget @@ -148,7 +152,8 @@ def generate_map(self) -> None: return # Session name will be used for a directory name as well as a zip file name. - session_name = str(time()).replace(".", "_") + timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S") + session_name = f"{game.code}_{timestamp}" # st.info("Started map generation...", icon="⏳") self.status_container.info("Started map generation...", icon="⏳")