maps4fs 0.6.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
maps4fs/__init__.py ADDED
@@ -0,0 +1,4 @@
1
+ # pylint: disable=missing-module-docstring
2
+ from maps4fs.generator.game import Game
3
+ from maps4fs.generator.map import Map
4
+ from maps4fs.logger import Logger
@@ -0,0 +1 @@
1
+ # pylint: disable=missing-module-docstring
@@ -0,0 +1,55 @@
1
+ """This module contains the base class for all map generation components."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Any
6
+
7
+ if TYPE_CHECKING:
8
+ from maps4fs.generator.game import Game
9
+
10
+
11
+ # pylint: disable=R0801, R0903
12
+ class Component:
13
+ """Base class for all map generation components.
14
+
15
+ Args:
16
+ coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
17
+ distance (int): The distance from the center to the edge of the map.
18
+ map_directory (str): The directory where the map files are stored.
19
+ logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
20
+ info, warning. If not provided, default logging will be used.
21
+ """
22
+
23
+ def __init__(
24
+ self,
25
+ game: Game,
26
+ coordinates: tuple[float, float],
27
+ distance: int,
28
+ map_directory: str,
29
+ logger: Any = None,
30
+ **kwargs, # pylint: disable=W0613, R0913, R0917
31
+ ):
32
+ self.game = game
33
+ self.coordinates = coordinates
34
+ self.distance = distance
35
+ self.map_directory = map_directory
36
+ self.logger = logger
37
+ self.kwargs = kwargs
38
+
39
+ self.preprocess()
40
+
41
+ def preprocess(self) -> None:
42
+ """Prepares the component for processing. Must be implemented in the child class.
43
+
44
+ Raises:
45
+ NotImplementedError: If the method is not implemented in the child class.
46
+ """
47
+ raise NotImplementedError
48
+
49
+ def process(self) -> None:
50
+ """Launches the component processing. Must be implemented in the child class.
51
+
52
+ Raises:
53
+ NotImplementedError: If the method is not implemented in the child class.
54
+ """
55
+ raise NotImplementedError
@@ -0,0 +1,44 @@
1
+ """This module contains the Config class for map settings and configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from xml.etree import ElementTree as ET
7
+
8
+ from maps4fs.generator.component import Component
9
+
10
+
11
+ # pylint: disable=R0903
12
+ class Config(Component):
13
+ """Component for map settings and configuration.
14
+
15
+ Args:
16
+ coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
17
+ distance (int): The distance from the center to the edge of the map.
18
+ map_directory (str): The directory where the map files are stored.
19
+ logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
20
+ info, warning. If not provided, default logging will be used.
21
+ """
22
+
23
+ def preprocess(self) -> None:
24
+ self._map_xml_path = self.game.map_xml_path(self.map_directory)
25
+ self.logger.debug(f"Map XML path: {self._map_xml_path}")
26
+
27
+ def process(self):
28
+ self._set_map_size()
29
+
30
+ def _set_map_size(self):
31
+ """Edits map.xml file to set correct map size."""
32
+ if not os.path.isfile(self._map_xml_path):
33
+ self.logger.warning("Map XML file not found: %s.", self._map_xml_path)
34
+ return
35
+ tree = ET.parse(self._map_xml_path)
36
+ self.logger.debug("Map XML file loaded from: %s.", self._map_xml_path)
37
+ root = tree.getroot()
38
+ for map_elem in root.iter("map"):
39
+ width = height = str(self.distance * 2)
40
+ map_elem.set("width", width)
41
+ map_elem.set("height", height)
42
+ self.logger.debug("Map size set to %sx%s in Map XML file.", width, height)
43
+ tree.write(self._map_xml_path)
44
+ self.logger.debug("Map XML file saved to: %s.", self._map_xml_path)
@@ -0,0 +1,207 @@
1
+ """This module contains DEM class for processing Digital Elevation Model data."""
2
+
3
+ import gzip
4
+ import math
5
+ import os
6
+ import shutil
7
+
8
+ import cv2
9
+ import numpy as np
10
+ import osmnx as ox # type: ignore
11
+ import rasterio # type: ignore
12
+ import requests
13
+
14
+ from maps4fs.generator.component import Component
15
+
16
+ SRTM = "https://elevation-tiles-prod.s3.amazonaws.com/skadi/{latitude_band}/{tile_name}.hgt.gz"
17
+
18
+
19
+ # pylint: disable=R0903
20
+ class DEM(Component):
21
+ """Component for map settings and configuration.
22
+
23
+ Args:
24
+ coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
25
+ distance (int): The distance from the center to the edge of the map.
26
+ map_directory (str): The directory where the map files are stored.
27
+ logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
28
+ info, warning. If not provided, default logging will be used.
29
+ """
30
+
31
+ def preprocess(self) -> None:
32
+ self._blur_seed: int = self.kwargs.get("blur_seed") or 5
33
+ self._max_height: int = self.kwargs.get("max_height") or 200
34
+
35
+ self._dem_path = self.game.dem_file_path(self.map_directory)
36
+ self.temp_dir = "temp"
37
+ self.hgt_dir = os.path.join(self.temp_dir, "hgt")
38
+ self.gz_dir = os.path.join(self.temp_dir, "gz")
39
+ os.makedirs(self.hgt_dir, exist_ok=True)
40
+ os.makedirs(self.gz_dir, exist_ok=True)
41
+
42
+ # pylint: disable=no-member
43
+ def process(self) -> None:
44
+ """Reads SRTM file, crops it to map size, normalizes and blurs it,
45
+ saves to map directory."""
46
+ north, south, east, west = ox.utils_geo.bbox_from_point( # pylint: disable=W0632
47
+ self.coordinates, dist=self.distance
48
+ )
49
+ self.logger.debug(
50
+ f"Processing DEM. North: {north}, south: {south}, east: {east}, west: {west}."
51
+ )
52
+
53
+ dem_output_size = self.distance * self.game.dem_multipliyer + 1
54
+ self.logger.debug(
55
+ "DEM multiplier is %s, DEM output size is %s.",
56
+ self.game.dem_multipliyer,
57
+ dem_output_size,
58
+ )
59
+ dem_output_resolution = (dem_output_size, dem_output_size)
60
+ self.logger.debug("DEM output resolution: %s.", dem_output_resolution)
61
+
62
+ tile_path = self._srtm_tile()
63
+ if not tile_path:
64
+ self.logger.warning("Tile was not downloaded, DEM file will be filled with zeros.")
65
+ self._save_empty_dem(dem_output_resolution)
66
+ return
67
+
68
+ with rasterio.open(tile_path) as src:
69
+ self.logger.debug("Opened tile, shape: %s, dtype: %s.", src.shape, src.dtypes[0])
70
+ window = rasterio.windows.from_bounds(west, south, east, north, src.transform)
71
+ self.logger.debug(
72
+ "Window parameters. Column offset: %s, row offset: %s, width: %s, height: %s.",
73
+ window.col_off,
74
+ window.row_off,
75
+ window.width,
76
+ window.height,
77
+ )
78
+ data = src.read(1, window=window)
79
+
80
+ if not data.size > 0:
81
+ self.logger.warning("DEM data is empty, DEM file will be filled with zeros.")
82
+ self._save_empty_dem(dem_output_resolution)
83
+ return
84
+
85
+ self.logger.debug(
86
+ f"DEM data was read from SRTM file. Shape: {data.shape}, dtype: {data.dtype}. "
87
+ f"Min: {data.min()}, max: {data.max()}."
88
+ )
89
+
90
+ normalized_data = self._normalize_dem(data)
91
+
92
+ resampled_data = cv2.resize(
93
+ normalized_data, dem_output_resolution, interpolation=cv2.INTER_LINEAR
94
+ )
95
+ self.logger.debug(
96
+ f"DEM data was resampled. Shape: {resampled_data.shape}, "
97
+ f"dtype: {resampled_data.dtype}. "
98
+ f"Min: {resampled_data.min()}, max: {resampled_data.max()}."
99
+ )
100
+
101
+ blurred_data = cv2.GaussianBlur(resampled_data, (self._blur_seed, self._blur_seed), 0)
102
+ cv2.imwrite(self._dem_path, blurred_data)
103
+ self.logger.debug("DEM data was saved to %s.", self._dem_path)
104
+
105
+ def _tile_info(self, lat: float, lon: float) -> tuple[str, str]:
106
+ """Returns latitude band and tile name for SRTM tile from coordinates.
107
+
108
+ Args:
109
+ lat (float): Latitude.
110
+ lon (float): Longitude.
111
+
112
+ Returns:
113
+ tuple[str, str]: Latitude band and tile name.
114
+ """
115
+ tile_latitude = math.floor(lat)
116
+ tile_longitude = math.floor(lon)
117
+
118
+ latitude_band = f"N{abs(tile_latitude):02d}" if lat >= 0 else f"S{abs(tile_latitude):02d}"
119
+ if lon < 0:
120
+ tile_name = f"{latitude_band}W{abs(tile_longitude):03d}"
121
+ else:
122
+ tile_name = f"{latitude_band}E{abs(tile_longitude):03d}"
123
+
124
+ self.logger.debug(
125
+ "Detected tile name: %s for coordinates: lat %s, lon %s.", tile_name, lat, lon
126
+ )
127
+ return latitude_band, tile_name
128
+
129
+ def _download_tile(self) -> str | None:
130
+ """Downloads SRTM tile from Amazon S3 using coordinates.
131
+
132
+ Returns:
133
+ str: Path to compressed tile or None if download failed.
134
+ """
135
+ latitude_band, tile_name = self._tile_info(*self.coordinates)
136
+ compressed_file_path = os.path.join(self.gz_dir, f"{tile_name}.hgt.gz")
137
+ url = SRTM.format(latitude_band=latitude_band, tile_name=tile_name)
138
+ self.logger.debug("Trying to get response from %s...", url)
139
+ response = requests.get(url, stream=True, timeout=10)
140
+
141
+ if response.status_code == 200:
142
+ self.logger.debug("Response received. Saving to %s...", compressed_file_path)
143
+ with open(compressed_file_path, "wb") as f:
144
+ for chunk in response.iter_content(chunk_size=8192):
145
+ f.write(chunk)
146
+ self.logger.debug("Compressed tile successfully downloaded.")
147
+ else:
148
+ self.logger.error("Response was failed with status code %s.", response.status_code)
149
+ return None
150
+
151
+ return compressed_file_path
152
+
153
+ def _srtm_tile(self) -> str | None:
154
+ """Determines SRTM tile name from coordinates downloads it if necessary, and decompresses.
155
+
156
+ Returns:
157
+ str: Path to decompressed tile or None if download failed.
158
+ """
159
+ latitude_band, tile_name = self._tile_info(*self.coordinates)
160
+ self.logger.debug("SRTM tile name %s from latitude band %s.", tile_name, latitude_band)
161
+
162
+ decompressed_file_path = os.path.join(self.hgt_dir, f"{tile_name}.hgt")
163
+ if os.path.isfile(decompressed_file_path):
164
+ self.logger.info(
165
+ f"Decompressed tile already exists: {decompressed_file_path}, skipping download."
166
+ )
167
+ return decompressed_file_path
168
+
169
+ compressed_file_path = self._download_tile()
170
+ if not compressed_file_path:
171
+ self.logger.error("Download from SRTM failed, DEM file will be filled with zeros.")
172
+ return None
173
+ with gzip.open(compressed_file_path, "rb") as f_in:
174
+ with open(decompressed_file_path, "wb") as f_out:
175
+ shutil.copyfileobj(f_in, f_out)
176
+ self.logger.debug("Tile decompressed to %s.", decompressed_file_path)
177
+ return decompressed_file_path
178
+
179
+ def _save_empty_dem(self, dem_output_resolution: tuple[int, int]) -> None:
180
+ """Saves empty DEM file filled with zeros."""
181
+ dem_data = np.zeros(dem_output_resolution, dtype="uint16")
182
+ cv2.imwrite(self._dem_path, dem_data) # pylint: disable=no-member
183
+ self.logger.warning("DEM data filled with zeros and saved to %s.", self._dem_path)
184
+
185
+ def _normalize_dem(self, data: np.ndarray) -> np.ndarray:
186
+ """Normalize DEM data to 16-bit unsigned integer using max height from settings.
187
+
188
+ Args:
189
+ data (np.ndarray): DEM data from SRTM file after cropping.
190
+
191
+ Returns:
192
+ np.ndarray: Normalized DEM data.
193
+ """
194
+ max_dev = data.max() - data.min()
195
+ scaling_factor = max_dev / self._max_height if max_dev < self._max_height else 1
196
+ adjusted_max_height = int(65535 * scaling_factor)
197
+ self.logger.debug(
198
+ f"Maximum deviation: {max_dev}. Scaling factor: {scaling_factor}. "
199
+ f"Adjusted max height: {adjusted_max_height}."
200
+ )
201
+ normalized_data = (
202
+ (data - data.min()) / (data.max() - data.min()) * adjusted_max_height
203
+ ).astype("uint16")
204
+ self.logger.debug(
205
+ f"DEM data was normalized to {normalized_data.min()} - {normalized_data.max()}."
206
+ )
207
+ return normalized_data
@@ -0,0 +1,140 @@
1
+ """This module contains the Game class and its subclasses. Game class is used to define
2
+ different versions of the game for which the map is generated. Each game has its own map
3
+ template file and specific settings for map generation."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import os
8
+
9
+ from maps4fs.generator.config import Config
10
+ from maps4fs.generator.dem import DEM
11
+ from maps4fs.generator.texture import Texture
12
+
13
+ working_directory = os.getcwd()
14
+
15
+
16
+ class Game:
17
+ """Class used to define different versions of the game for which the map is generated.
18
+
19
+ Arguments:
20
+ map_template_path (str, optional): Path to the map template file. Defaults to None.
21
+
22
+ Attributes and Properties:
23
+ code (str): The code of the game.
24
+ components (list[Type[Component]]): List of components used for map generation.
25
+ map_template_path (str): Path to the map template file.
26
+
27
+ Public Methods:
28
+ from_code(cls, code: str) -> Game: Returns the game instance based on the game code.
29
+ """
30
+
31
+ code: str | None = None
32
+ dem_multipliyer: int = 1
33
+ _map_template_path: str | None = None
34
+ _texture_schema: str | None = None
35
+
36
+ components = [Config, Texture, DEM]
37
+
38
+ def __init__(self, map_template_path: str | None = None):
39
+ if map_template_path:
40
+ self._map_template_path = map_template_path
41
+
42
+ def map_xml_path(self, map_directory: str) -> str:
43
+ """Returns the path to the map.xml file.
44
+
45
+ Arguments:
46
+ map_directory (str): The path to the map directory.
47
+
48
+ Returns:
49
+ str: The path to the map.xml file.
50
+ """
51
+ return os.path.join(map_directory, "maps", "map", "map.xml")
52
+
53
+ @classmethod
54
+ def from_code(cls, code: str) -> Game:
55
+ """Returns the game instance based on the game code.
56
+
57
+ Arguments:
58
+ code (str): The code of the game.
59
+
60
+ Returns:
61
+ Game: The game instance.
62
+ """
63
+ for game in cls.__subclasses__():
64
+ if game.code and game.code.lower() == code.lower():
65
+ return game()
66
+ raise ValueError(f"Game with code {code} not found.")
67
+
68
+ @property
69
+ def template_path(self) -> str:
70
+ """Returns the path to the map template file.
71
+
72
+ Raises:
73
+ ValueError: If the map template path is not set.
74
+
75
+ Returns:
76
+ str: The path to the map template file."""
77
+ if not self._map_template_path:
78
+ raise ValueError("Map template path not set.")
79
+ return self._map_template_path
80
+
81
+ @property
82
+ def texture_schema(self) -> str:
83
+ """Returns the path to the texture layers schema file.
84
+
85
+ Raises:
86
+ ValueError: If the texture layers schema path is not set.
87
+
88
+ Returns:
89
+ str: The path to the texture layers schema file."""
90
+ if not self._texture_schema:
91
+ raise ValueError("Texture layers schema path not set.")
92
+ return self._texture_schema
93
+
94
+ def dem_file_path(self, map_directory: str) -> str:
95
+ """Returns the path to the DEM file.
96
+
97
+ Arguments:
98
+ map_directory (str): The path to the map directory.
99
+
100
+ Returns:
101
+ str: The path to the DEM file.
102
+ """
103
+ raise NotImplementedError
104
+
105
+
106
+ class FS22(Game):
107
+ """Class used to define the game version FS22."""
108
+
109
+ code = "FS22"
110
+ _map_template_path = os.path.join(working_directory, "data", "fs22-map-template.zip")
111
+ _texture_schema = os.path.join(working_directory, "data", "fs22-texture-schema.json")
112
+
113
+ def dem_file_path(self, map_directory: str) -> str:
114
+ """Returns the path to the DEM file.
115
+
116
+ Arguments:
117
+ map_directory (str): The path to the map directory.
118
+
119
+ Returns:
120
+ str: The path to the DEM file."""
121
+ return os.path.join(map_directory, "maps", "map", "data", "map_dem.png")
122
+
123
+
124
+ class FS25(Game):
125
+ """Class used to define the game version FS25."""
126
+
127
+ code = "FS25"
128
+ dem_multipliyer: int = 2
129
+ _map_template_path = os.path.join(working_directory, "data", "fs25-map-template.zip")
130
+ _texture_schema = os.path.join(working_directory, "data", "fs25-texture-schema.json")
131
+
132
+ def dem_file_path(self, map_directory: str) -> str:
133
+ """Returns the path to the DEM file.
134
+
135
+ Arguments:
136
+ map_directory (str): The path to the map directory.
137
+
138
+ Returns:
139
+ str: The path to the DEM file."""
140
+ return os.path.join(map_directory, "maps", "map", "data", "dem.png")
@@ -0,0 +1,110 @@
1
+ """This module contains Map class, which is used to generate map using all components."""
2
+
3
+ import os
4
+ import shutil
5
+ from typing import Any
6
+
7
+ from tqdm import tqdm
8
+
9
+ from maps4fs.generator.game import Game
10
+ from maps4fs.logger import Logger
11
+
12
+
13
+ # pylint: disable=R0913
14
+ class Map:
15
+ """Class used to generate map using all components.
16
+
17
+ Args:
18
+ game (Type[Game]): Game for which the map is generated.
19
+ coordinates (tuple[float, float]): Coordinates of the center of the map.
20
+ distance (int): Distance from the center of the map.
21
+ map_directory (str): Path to the directory where map files will be stored.
22
+ blur_seed (int): Seed used for blur effect.
23
+ max_height (int): Maximum height of the map.
24
+ logger (Any): Logger instance
25
+ """
26
+
27
+ def __init__( # pylint: disable=R0917
28
+ self,
29
+ game: Game,
30
+ coordinates: tuple[float, float],
31
+ distance: int,
32
+ map_directory: str,
33
+ blur_seed: int,
34
+ max_height: int,
35
+ logger: Any = None,
36
+ ):
37
+ self.game = game
38
+ self.coordinates = coordinates
39
+ self.distance = distance
40
+ self.map_directory = map_directory
41
+
42
+ if not logger:
43
+ logger = Logger(__name__, to_stdout=True, to_file=False)
44
+ self.logger = logger
45
+ self.logger.debug("Game was set to %s", game.code)
46
+
47
+ os.makedirs(self.map_directory, exist_ok=True)
48
+ self.logger.debug("Map directory created: %s", self.map_directory)
49
+
50
+ try:
51
+ shutil.unpack_archive(game.template_path, self.map_directory)
52
+ self.logger.info("Map template unpacked to %s", self.map_directory)
53
+ except Exception as e:
54
+ raise RuntimeError(f"Can not unpack map template due to error: {e}") from e
55
+
56
+ # Blur seed should be positive and odd.
57
+ if blur_seed <= 0:
58
+ raise ValueError("Blur seed should be positive.")
59
+ if blur_seed % 2 == 0:
60
+ blur_seed += 1
61
+
62
+ self.blur_seed = blur_seed
63
+ self.max_height = max_height
64
+
65
+ def generate(self) -> None:
66
+ """Launch map generation using all components."""
67
+ with tqdm(total=len(self.game.components), desc="Generating map...") as pbar:
68
+ for game_component in self.game.components:
69
+ component = game_component(
70
+ self.game,
71
+ self.coordinates,
72
+ self.distance,
73
+ self.map_directory,
74
+ self.logger,
75
+ blur_seed=self.blur_seed,
76
+ max_height=self.max_height,
77
+ )
78
+ try:
79
+ component.process()
80
+ except Exception as e: # pylint: disable=W0718
81
+ self.logger.error(
82
+ "Error processing component %s: %s",
83
+ component.__class__.__name__,
84
+ e,
85
+ )
86
+ raise e
87
+ setattr(self, game_component.__name__.lower(), component)
88
+
89
+ pbar.update(1)
90
+
91
+ def previews(self) -> list[str]:
92
+ """Get list of preview images.
93
+
94
+ Returns:
95
+ list[str]: List of preview images.
96
+ """
97
+ return self.texture.previews() # type: ignore # pylint: disable=no-member
98
+
99
+ def pack(self, archive_name: str) -> str:
100
+ """Pack map directory to zip archive.
101
+
102
+ Args:
103
+ archive_name (str): Name of the archive.
104
+
105
+ Returns:
106
+ str: Path to the archive.
107
+ """
108
+ archive_path = shutil.make_archive(archive_name, "zip", self.map_directory)
109
+ self.logger.info("Map packed to %s.zip", archive_name)
110
+ return archive_path
@@ -0,0 +1,535 @@
1
+ """Module with Texture class for generating textures for the map using OSM data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import warnings
8
+ from typing import Callable, Generator, Optional
9
+
10
+ import cv2
11
+ import numpy as np
12
+ import osmnx as ox # type: ignore
13
+ import pandas as pd
14
+ import shapely.geometry # type: ignore
15
+ from shapely.geometry.base import BaseGeometry # type: ignore
16
+
17
+ from maps4fs.generator.component import Component
18
+
19
+ # region constants
20
+ # texture = {
21
+ # "name": "concrete",
22
+ # "count": 4,
23
+ # "tags": {"building": True},
24
+ # "width": 8,
25
+ # "color": (130, 130, 130),
26
+ # }
27
+
28
+ # textures = [
29
+ # {
30
+ # "name": "animalMud",
31
+ # "count": 4,
32
+ # },
33
+ # {
34
+ # "name": "asphalt",
35
+ # "count": 4,
36
+ # "tags": {"highway": ["motorway", "trunk", "primary"]},
37
+ # "width": 8,
38
+ # "color": (70, 70, 70),
39
+ # },
40
+ # {
41
+ # "name": "cobbleStone",
42
+ # "count": 4,
43
+ # },
44
+ # {
45
+ # "name": "concrete",
46
+ # "count": 4,
47
+ # "tags": {"building": True},
48
+ # "width": 8,
49
+ # "color": (130, 130, 130),
50
+ # },
51
+ # {
52
+ # "name": "concreteRubble",
53
+ # "count": 4,
54
+ # },
55
+ # {
56
+ # "name": "concreteTiles",
57
+ # "count": 4,
58
+ # },
59
+ # {
60
+ # "name": "dirt",
61
+ # "count": 4,
62
+ # },
63
+ # {
64
+ # "name": "dirtDark",
65
+ # "count": 2,
66
+ # "tags": {"highway": ["unclassified", "residential", "track"]},
67
+ # "width": 2,
68
+ # "color": (33, 67, 101),
69
+ # },
70
+ # {
71
+ # "name": "forestGround",
72
+ # "count": 4,
73
+ # "tags": {"landuse": "farmland"},
74
+ # "color": (47, 107, 85),
75
+ # },
76
+ # {
77
+ # "name": "forestGroundLeaves",
78
+ # "count": 4,
79
+ # },
80
+ # {
81
+ # "name": "grass",
82
+ # "count": 4,
83
+ # "tags": {"natural": "grassland"},
84
+ # "color": (34, 255, 34),
85
+ # },
86
+ # {
87
+ # "name": "grassDirt",
88
+ # "count": 4,
89
+ # "tags": {"natural": ["wood", "tree_row"]},
90
+ # "width": 2,
91
+ # "color": (0, 252, 124),
92
+ # },
93
+ # {
94
+ # "name": "gravel",
95
+ # "count": 4,
96
+ # "tags": {"highway": ["secondary", "tertiary", "road"]},
97
+ # "width": 4,
98
+ # "color": (140, 180, 210),
99
+ # },
100
+ # {
101
+ # "name": "groundBricks",
102
+ # "count": 4,
103
+ # },
104
+ # {
105
+ # "name": "mountainRock",
106
+ # "count": 4,
107
+ # },
108
+ # {
109
+ # "name": "mountainRockDark",
110
+ # "count": 4,
111
+ # },
112
+ # {
113
+ # "name": "riverMud",
114
+ # "count": 4,
115
+ # },
116
+ # {
117
+ # "name": "waterPuddle",
118
+ # "count": 0,
119
+ # "tags": {"natural": "water", "waterway": True},
120
+ # "width": 10,
121
+ # "color": (255, 20, 20),
122
+ # },
123
+ # ]
124
+
125
+ # TEXTURES = {
126
+ # ? "animalMud": 4,
127
+ # ? "asphalt": 4,
128
+ # ? "cobbleStone": 4,
129
+ # ? "concrete": 4,
130
+ # "concreteRubble": 4,
131
+ # "concreteTiles": 4,
132
+ # "dirt": 4,
133
+ # "dirtDark": 2,
134
+ # "forestGround": 4,
135
+ # "forestGroundLeaves": 4,
136
+ # "grass": 4,
137
+ # "grassDirt": 4,
138
+ # "gravel": 4,
139
+ # "groundBricks": 4,
140
+ # "mountainRock": 4,
141
+ # "mountainRockDark": 4,
142
+ # "riverMud": 4,
143
+ # "waterPuddle": 0,
144
+ # }
145
+ # endregion
146
+
147
+
148
+ # pylint: disable=R0902
149
+ class Texture(Component):
150
+ """Class which generates textures for the map using OSM data.
151
+
152
+ Attributes:
153
+ weights_dir (str): Path to the directory with weights.
154
+ name (str): Name of the texture.
155
+ tags (dict[str, str | list[str] | bool]): Dictionary of tags to search for.
156
+ width (int | None): Width of the polygon in meters (only for LineString).
157
+ color (tuple[int, int, int]): Color of the layer in BGR format.
158
+ """
159
+
160
+ # pylint: disable=R0903
161
+ class Layer:
162
+ """Class which represents a layer with textures and tags.
163
+ It's using to obtain data from OSM using tags and make changes into corresponding textures.
164
+
165
+ Args:
166
+ name (str): Name of the layer.
167
+ tags (dict[str, str | list[str]]): Dictionary of tags to search for.
168
+ width (int | None): Width of the polygon in meters (only for LineString).
169
+ color (tuple[int, int, int]): Color of the layer in BGR format.
170
+
171
+ Attributes:
172
+ name (str): Name of the layer.
173
+ tags (dict[str, str | list[str]]): Dictionary of tags to search for.
174
+ width (int | None): Width of the polygon in meters (only for LineString).
175
+ """
176
+
177
+ # pylint: disable=R0913
178
+ def __init__( # pylint: disable=R0917
179
+ self,
180
+ name: str,
181
+ count: int,
182
+ tags: dict[str, str | list[str] | bool] | None = None,
183
+ width: int | None = None,
184
+ color: tuple[int, int, int] | list[int] | None = None,
185
+ ):
186
+ self.name = name
187
+ self.count = count
188
+ self.tags = tags
189
+ self.width = width
190
+ self.color = color if color else (255, 255, 255)
191
+
192
+ def to_json(self) -> dict[str, str | list[str] | bool]: # type: ignore
193
+ """Returns dictionary with layer data.
194
+
195
+ Returns:
196
+ dict: Dictionary with layer data."""
197
+ data = {
198
+ "name": self.name,
199
+ "count": self.count,
200
+ "tags": self.tags,
201
+ "width": self.width,
202
+ "color": list(self.color),
203
+ }
204
+
205
+ data = {k: v for k, v in data.items() if v is not None}
206
+ return data # type: ignore
207
+
208
+ @classmethod
209
+ def from_json(cls, data: dict[str, str | list[str] | bool]) -> Texture.Layer:
210
+ """Creates a new instance of the class from dictionary.
211
+
212
+ Args:
213
+ data (dict[str, str | list[str] | bool]): Dictionary with layer data.
214
+
215
+ Returns:
216
+ Layer: New instance of the class.
217
+ """
218
+ return cls(**data) # type: ignore
219
+
220
+ def path(self, weights_directory: str) -> str:
221
+ """Returns path to the first texture of the layer.
222
+
223
+ Arguments:
224
+ weights_directory (str): Path to the directory with weights.
225
+
226
+ Returns:
227
+ str: Path to the texture.
228
+ """
229
+ if self.name == "waterPuddle":
230
+ return os.path.join(weights_directory, "waterPuddle_weight.png")
231
+ return os.path.join(weights_directory, f"{self.name}01_weight.png")
232
+
233
+ def preprocess(self) -> None:
234
+ if not os.path.isfile(self.game.texture_schema):
235
+ raise FileNotFoundError(f"Texture layers schema not found: {self.game.texture_schema}")
236
+
237
+ try:
238
+ with open(self.game.texture_schema, "r", encoding="utf-8") as f:
239
+ layers_schema = json.load(f)
240
+ except json.JSONDecodeError as e:
241
+ raise ValueError(f"Error loading texture layers schema: {e}") from e
242
+
243
+ self.layers = [self.Layer.from_json(layer) for layer in layers_schema]
244
+ self.logger.info("Loaded %s layers.", len(self.layers))
245
+
246
+ self._weights_dir = os.path.join(self.map_directory, "maps", "map", "data")
247
+ self._bbox = ox.utils_geo.bbox_from_point(self.coordinates, dist=self.distance)
248
+ self.info_save_path = os.path.join(self.map_directory, "generation_info.json")
249
+
250
+ def process(self):
251
+ self._prepare_weights()
252
+ self._read_parameters()
253
+ self.draw()
254
+ self.info_sequence()
255
+
256
+ # pylint: disable=W0201
257
+ def _read_parameters(self) -> None:
258
+ """Reads map parameters from OSM data, such as:
259
+ - minimum and maximum coordinates in UTM format
260
+ - map dimensions in meters
261
+ - map coefficients (meters per pixel)
262
+ """
263
+ north, south, east, west = ox.utils_geo.bbox_from_point( # pylint: disable=W0632
264
+ self.coordinates, dist=self.distance, project_utm=True
265
+ )
266
+ # Parameters of the map in UTM format (meters).
267
+ self.minimum_x = min(west, east)
268
+ self.minimum_y = min(south, north)
269
+ self.maximum_x = max(west, east)
270
+ self.maximum_y = max(south, north)
271
+ self.logger.debug("Map minimum coordinates (XxY): %s x %s.", self.minimum_x, self.minimum_y)
272
+ self.logger.debug("Map maximum coordinates (XxY): %s x %s.", self.maximum_x, self.maximum_y)
273
+
274
+ self.height = abs(north - south)
275
+ self.width = abs(east - west)
276
+ self.logger.info("Map dimensions (HxW): %s x %s.", self.height, self.width)
277
+
278
+ self.height_coef = self.height / (self.distance * 2)
279
+ self.width_coef = self.width / (self.distance * 2)
280
+ self.logger.debug("Map coefficients (HxW): %s x %s.", self.height_coef, self.width_coef)
281
+
282
+ def info_sequence(self) -> None:
283
+ """Saves generation info to JSON file "generation_info.json".
284
+
285
+ Info sequence contains following attributes:
286
+ - coordinates
287
+ - bbox
288
+ - distance
289
+ - minimum_x
290
+ - minimum_y
291
+ - maximum_x
292
+ - maximum_y
293
+ - height
294
+ - width
295
+ - height_coef
296
+ - width_coef
297
+ """
298
+ useful_attributes = [
299
+ "coordinates",
300
+ "bbox",
301
+ "distance",
302
+ "minimum_x",
303
+ "minimum_y",
304
+ "maximum_x",
305
+ "maximum_y",
306
+ "height",
307
+ "width",
308
+ "height_coef",
309
+ "width_coef",
310
+ ]
311
+ info_sequence = {attr: getattr(self, attr, None) for attr in useful_attributes}
312
+
313
+ with open(self.info_save_path, "w") as f: # pylint: disable=W1514
314
+ json.dump(info_sequence, f, indent=4)
315
+ self.logger.info("Generation info saved to %s.", self.info_save_path)
316
+
317
+ def _prepare_weights(self):
318
+ self.logger.debug("Starting preparing weights from %s layers.", len(self.layers))
319
+
320
+ for layer in self.layers:
321
+ self._generate_weights(layer.name, layer.count)
322
+ self.logger.debug("Prepared weights for %s layers.", len(self.layers))
323
+
324
+ def _generate_weights(self, texture_name: str, layer_numbers: int) -> None:
325
+ """Generates weight files for textures. Each file is a numpy array of zeros and
326
+ dtype uint8 (0-255).
327
+
328
+ Args:
329
+ texture_name (str): Name of the texture.
330
+ layer_numbers (int): Number of layers in the texture.
331
+ """
332
+ size = self.distance * 2
333
+ postfix = "_weight.png"
334
+ if layer_numbers == 0:
335
+ filepaths = [os.path.join(self._weights_dir, texture_name + postfix)]
336
+ else:
337
+ filepaths = [
338
+ os.path.join(self._weights_dir, texture_name + str(i).zfill(2) + postfix)
339
+ for i in range(1, layer_numbers + 1)
340
+ ]
341
+
342
+ for filepath in filepaths:
343
+ img = np.zeros((size, size), dtype=np.uint8)
344
+ cv2.imwrite(filepath, img) # pylint: disable=no-member
345
+
346
+ @property
347
+ def layers(self) -> list[Layer]:
348
+ """Returns list of layers with textures and tags from textures.json.
349
+
350
+ Returns:
351
+ list[Layer]: List of layers.
352
+ """
353
+ return self._layers
354
+
355
+ @layers.setter
356
+ def layers(self, layers: list[Layer]) -> None:
357
+ """Sets list of layers with textures and tags.
358
+
359
+ Args:
360
+ layers (list[Layer]): List of layers.
361
+ """
362
+ self._layers = layers
363
+
364
+ # pylint: disable=no-member
365
+ def draw(self) -> None:
366
+ """Iterates over layers and fills them with polygons from OSM data."""
367
+ for layer in self.layers:
368
+ layer_path = layer.path(self._weights_dir)
369
+ self.logger.debug("Drawing layer %s.", layer_path)
370
+
371
+ img = cv2.imread(layer_path, cv2.IMREAD_UNCHANGED)
372
+ for polygon in self.polygons(layer.tags, layer.width): # type: ignore
373
+ cv2.fillPoly(img, [polygon], color=255) # type: ignore
374
+ cv2.imwrite(layer_path, img)
375
+ self.logger.debug("Texture %s saved.", layer.path)
376
+
377
+ def get_relative_x(self, x: float) -> int:
378
+ """Converts UTM X coordinate to relative X coordinate in map image.
379
+
380
+ Args:
381
+ x (float): UTM X coordinate.
382
+
383
+ Returns:
384
+ int: Relative X coordinate in map image.
385
+ """
386
+ raw_x = x - self.minimum_x
387
+ return int(raw_x * self.height_coef)
388
+
389
+ def get_relative_y(self, y: float) -> int:
390
+ """Converts UTM Y coordinate to relative Y coordinate in map image.
391
+
392
+ Args:
393
+ y (float): UTM Y coordinate.
394
+
395
+ Returns:
396
+ int: Relative Y coordinate in map image.
397
+ """
398
+ raw_y = y - self.minimum_y
399
+ return self.height - int(raw_y * self.width_coef)
400
+
401
+ # pylint: disable=W0613
402
+ def _to_np(self, geometry: shapely.geometry.polygon.Polygon, *args) -> np.ndarray:
403
+ """Converts Polygon geometry to numpy array of polygon points.
404
+
405
+ Args:
406
+ geometry (shapely.geometry.polygon.Polygon): Polygon geometry.
407
+ *args: Additional arguments:
408
+ - width (int | None): Width of the polygon in meters.
409
+
410
+ Returns:
411
+ np.ndarray: Numpy array of polygon points.
412
+ """
413
+ xs, ys = geometry.exterior.coords.xy
414
+ xs = [int(self.get_relative_x(x)) for x in xs.tolist()]
415
+ ys = [int(self.get_relative_y(y)) for y in ys.tolist()]
416
+ pairs = list(zip(xs, ys))
417
+ return np.array(pairs, dtype=np.int32).reshape((-1, 1, 2))
418
+
419
+ def _to_polygon(self, obj: pd.core.series.Series, width: int | None) -> np.ndarray | None:
420
+ """Converts OSM object to numpy array of polygon points.
421
+
422
+ Args:
423
+ obj (pd.core.series.Series): OSM object.
424
+ width (int | None): Width of the polygon in meters.
425
+
426
+ Returns:
427
+ np.ndarray | None: Numpy array of polygon points.
428
+ """
429
+ geometry = obj["geometry"]
430
+ geometry_type = geometry.geom_type
431
+ converter = self._converters(geometry_type)
432
+ if not converter:
433
+ self.logger.warning("Geometry type %s not supported.", geometry_type)
434
+ return None
435
+ return converter(geometry, width)
436
+
437
+ def _sequence(
438
+ self,
439
+ geometry: shapely.geometry.linestring.LineString | shapely.geometry.point.Point,
440
+ width: int | None,
441
+ ) -> np.ndarray:
442
+ """Converts LineString or Point geometry to numpy array of polygon points.
443
+
444
+ Args:
445
+ geometry (shapely.geometry.linestring.LineString | shapely.geometry.point.Point):
446
+ LineString or Point geometry.
447
+ width (int | None): Width of the polygon in meters.
448
+
449
+ Returns:
450
+ np.ndarray: Numpy array of polygon points.
451
+ """
452
+ polygon = geometry.buffer(width)
453
+ return self._to_np(polygon)
454
+
455
+ def _converters(
456
+ self, geom_type: str
457
+ ) -> Optional[Callable[[BaseGeometry, Optional[int]], np.ndarray]]:
458
+ """Returns a converter function for a given geometry type.
459
+
460
+ Args:
461
+ geom_type (str): Geometry type.
462
+
463
+ Returns:
464
+ Callable[[shapely.geometry, int | None], np.ndarray]: Converter function.
465
+ """
466
+ converters = {"Polygon": self._to_np, "LineString": self._sequence, "Point": self._sequence}
467
+ return converters.get(geom_type) # type: ignore
468
+
469
+ def polygons(
470
+ self, tags: dict[str, str | list[str] | bool], width: int | None
471
+ ) -> Generator[np.ndarray, None, None]:
472
+ """Generator which yields numpy arrays of polygons from OSM data.
473
+
474
+ Args:
475
+ tags (dict[str, str | list[str]]): Dictionary of tags to search for.
476
+ width (int | None): Width of the polygon in meters (only for LineString).
477
+
478
+ Yields:
479
+ Generator[np.ndarray, None, None]: Numpy array of polygon points.
480
+ """
481
+ try:
482
+ with warnings.catch_warnings():
483
+ warnings.simplefilter("ignore", DeprecationWarning)
484
+ objects = ox.features_from_bbox(bbox=self._bbox, tags=tags)
485
+ except Exception as e: # pylint: disable=W0718
486
+ self.logger.warning("Error fetching objects for tags: %s.", tags)
487
+ self.logger.warning(e)
488
+ return
489
+ objects_utm = ox.project_gdf(objects, to_latlong=False)
490
+ self.logger.debug("Fetched %s elements for tags: %s.", len(objects_utm), tags)
491
+
492
+ for _, obj in objects_utm.iterrows():
493
+ polygon = self._to_polygon(obj, width)
494
+ if polygon is None:
495
+ continue
496
+ yield polygon
497
+
498
+ def previews(self) -> list[str]:
499
+ """Invokes methods to generate previews. Returns list of paths to previews.
500
+
501
+ Returns:
502
+ list[str]: List of paths to previews.
503
+ """
504
+ preview_paths = []
505
+ preview_paths.append(self._osm_preview())
506
+ return preview_paths
507
+
508
+ # pylint: disable=no-member
509
+ def _osm_preview(self) -> str:
510
+ """Merges layers into one image and saves it into the png file.
511
+
512
+ Returns:
513
+ str: Path to the preview.
514
+ """
515
+ preview_size = (2048, 2048)
516
+ images = [
517
+ cv2.resize(
518
+ cv2.imread(layer.path(self._weights_dir), cv2.IMREAD_UNCHANGED), preview_size
519
+ )
520
+ for layer in self.layers
521
+ ]
522
+ colors = [layer.color for layer in self.layers]
523
+ color_images = []
524
+ for img, color in zip(images, colors):
525
+ color_img = np.zeros((img.shape[0], img.shape[1], 3), dtype=np.uint8)
526
+ color_img[img > 0] = color
527
+ color_images.append(color_img)
528
+ merged = np.sum(color_images, axis=0, dtype=np.uint8)
529
+ self.logger.debug(
530
+ f"Merged layers into one image. Shape: {merged.shape}, dtype: {merged.dtype}."
531
+ )
532
+ preview_path = os.path.join(self.map_directory, "preview_osm.png")
533
+ cv2.imwrite(preview_path, merged) # pylint: disable=no-member
534
+ self.logger.info("Preview saved to %s.", preview_path)
535
+ return preview_path
maps4fs/logger.py ADDED
@@ -0,0 +1,46 @@
1
+ """This module contains the Logger class for logging to the file and stdout."""
2
+
3
+ import logging
4
+ import os
5
+ import sys
6
+ from datetime import datetime
7
+ from typing import Literal
8
+
9
+ log_directory = os.path.join(os.getcwd(), "logs")
10
+ os.makedirs(log_directory, exist_ok=True)
11
+
12
+
13
+ class Logger(logging.Logger):
14
+ """Handles logging to the file and stroudt with timestamps."""
15
+
16
+ def __init__(
17
+ self,
18
+ name: str,
19
+ level: Literal["DEBUG", "INFO", "WARNING", "ERROR"] = "ERROR",
20
+ to_stdout: bool = True,
21
+ to_file: bool = True,
22
+ ):
23
+ super().__init__(name)
24
+ self.setLevel(level)
25
+ self.stdout_handler = logging.StreamHandler(sys.stdout)
26
+ self.file_handler = logging.FileHandler(
27
+ filename=self.log_file(), mode="a", encoding="utf-8"
28
+ )
29
+ formatter = "%(name)s | %(levelname)s | %(asctime)s | %(message)s"
30
+ self.fmt = formatter
31
+ self.stdout_handler.setFormatter(logging.Formatter(formatter))
32
+ self.file_handler.setFormatter(logging.Formatter(formatter))
33
+ if to_stdout:
34
+ self.addHandler(self.stdout_handler)
35
+ if to_file:
36
+ self.addHandler(self.file_handler)
37
+
38
+ def log_file(self) -> str:
39
+ """Returns the path to the log file.
40
+
41
+ Returns:
42
+ str: The path to the log file.
43
+ """
44
+ today = datetime.now().strftime("%Y-%m-%d")
45
+ log_file = os.path.join(log_directory, f"{today}.txt")
46
+ return log_file
@@ -0,0 +1,21 @@
1
+ # MIT License
2
+
3
+ Copyright © [2024] [iwatkot]
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.1
2
+ Name: maps4fs
3
+ Version: 0.6.0
4
+ Summary: Generate map templates for Farming Simulator from real places.
5
+ Author-email: iwatkot <iwatkot@gmail.com>
6
+ License: MIT License
7
+ Project-URL: Homepage, https://github.com/iwatkot/maps4fs
8
+ Project-URL: Repository, https://github.com/iwatkot/maps4fs
9
+ Keywords: farmingsimulator,fs,farmingsimulator22,farmingsimulator25,fs22,fs25
10
+ Classifier: Programming Language :: Python :: 3.11
11
+ Classifier: Programming Language :: Python :: 3.12
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Description-Content-Type: text/markdown
15
+ License-File: LICENSE.md
16
+ Requires-Dist: pydantic>=2.0.0
17
+ Requires-Dist: requests>=2.0.0
18
+ Requires-Dist: httpx>=0.20.0
19
+
20
+ <div align="center" markdown>
21
+ <img src="https://github.com/iwatkot/maps4fs/assets/118521851/ffd7f0a3-e317-4c3f-911f-2c2fb736fbfa">
22
+
23
+ <p align="center">
24
+ <a href="#Quick-Start">Quick Start</a> •
25
+ <a href="#Overview">Overview</a> •
26
+ <a href="#How-To-Run">How-To-Run</a> •
27
+ <a href="#Features">Features</a> •
28
+ <a href="#Supported-objects">Supported objects</a> •
29
+ <a href="Settings">Settings</a> •
30
+ <a href="#Bugs-and-feature-requests">Bugs and feature requests</a>
31
+ </p>
32
+
33
+
34
+ [![GitHub release (latest SemVer)](https://img.shields.io/github/v/release/iwatkot/maps4fs)](https://github.com/iwatkot/maps4fs/releases)
35
+ [![Docker Pulls](https://img.shields.io/docker/pulls/iwatkot/maps4fs)](https://hub.docker.com/repository/docker/iwatkot/maps4fs/general)
36
+ [![GitHub issues](https://img.shields.io/github/issues/iwatkot/maps4fs)](https://github.com/iwatkot/maps4fs/issues)
37
+ [![Maintainability](https://api.codeclimate.com/v1/badges/b922fd0a7188d37e61de/maintainability)](https://codeclimate.com/github/iwatkot/maps4fs/maintainability)<br>
38
+ [![Checked with mypy](https://www.mypy-lang.org/static/mypy_badge.svg)](https://mypy-lang.org/)
39
+ [![Build Status](https://github.com/iwatkot/maps4fs/actions/workflows/checks.yml/badge.svg)](https://github.com/iwatkot/maps4fs/actions)
40
+ [![Test Coverage](https://api.codeclimate.com/v1/badges/b922fd0a7188d37e61de/test_coverage)](https://codeclimate.com/github/iwatkot/maps4fs/test_coverage)
41
+ [![GitHub Repo stars](https://img.shields.io/github/stars/iwatkot/maps4fs)](https://github.com/iwatkot/maps4fs/stargazers)
42
+
43
+ </div>
44
+
45
+ ### Supported Games
46
+
47
+ ✅ Farming Simulator 22<br>
48
+ 🔃 Farming Simulator 25 (changes in the library are ready, waiting for the Giants to release the Giants Editor v10)<br>
49
+
50
+ ## Quick Start
51
+ For those, who don't want to read anything, here you go:<br>
52
+ **Option 1:** launch the [@maps4fs](https://t.me/maps4fsbot) Telegram bot and generate a map template in a few clicks.<br>
53
+ **Option 2:** run the Docker version in your browser. Launch the following command in your terminal:
54
+ ```bash
55
+ docker run -d -p 8501:8501 iwatkot/maps4fs
56
+ ```
57
+ And open [http://localhost:8501](http://localhost:8501) in your browser.<br>
58
+ If you don't know how to use Docker, navigate to the [Docker version](#option-2-docker-version), it's really simple.<br>
59
+
60
+ ## Overview
61
+ The core idea is coming from the awesome [maps4cim](https://github.com/klamann/maps4cim) project.<br>
62
+
63
+ The main goal of this project is to generate map templates, based on real-world data, for the Farming Simulator. It's important to mention that **templates are not maps**. They are just a starting point for creating a map. This tool just uses built-in textures to highlight different types of terrain and buildings with correct shapes and scales and to generate a height map. The rest of the work is up to you. So if you thought that you could just run this tool and get a playable map, then I'm sorry to disappoint you. But if you are a map maker, then this tool will save you a lot of time.<br>
64
+ So, if you're new to map making, here's a quick overview of the process:
65
+ 1. Generate a map template using this tool.
66
+ 2. Download the Giants Editor.
67
+ 3. Open the map template in the Giants Editor.
68
+ 4. Now you can start creating your map (adding roads, fields, buildings, etc.).
69
+
70
+ ## How-To-Run
71
+
72
+ You'll find detailed instructions on how to run the project below. But if you prefer video tutorials, here's one for you:
73
+ <a href="https://www.youtube.com/watch?v=ujwWKHVKsw8" target="_blank"><img src="https://github.com/user-attachments/assets/6dbbbc71-d04f-40b2-9fba-81e5e4857407"/></a>
74
+ <i>Video tutorial: How to generate a Farming Simulator 22 map from real-world data.</i>
75
+
76
+ ### Option 1: Telegram bot
77
+ **🗺️ Supported map sizes:** 2x2, 4x4 km.<br>
78
+ 🟢 Recommended for all users, you don't need to install anything.<br>
79
+ Using Telegram bot [@maps4fs](https://t.me/maps4fsbot).<br>
80
+ Note: due to CPU and RAM limitations of the hosting, only 2x2 and 4x4 km maps are available. If you need bigger maps, use the [Docker version](#option-2-docker-version).<br>
81
+
82
+ ![Telegram bot](https://github.com/iwatkot/maps4fs/assets/118521851/ede69fe8-1a34-4ede-908f-52c9dc355ae4)
83
+ <br>
84
+
85
+ Using it is easy and doesn't require any guides. Enjoy!
86
+
87
+ ### Option 2: Docker version
88
+ **🗺️ Supported map sizes:** 2x2, 4x4, 8x8, 16x16 km.<br>
89
+ 🟢 Recommended for users who need bigger maps, very simple installation.<br>
90
+ You can launch the project with minimalistic UI in your browser using Docker. Follow these steps:
91
+
92
+ 1. Install [Docker](https://docs.docker.com/get-docker/) for your OS.
93
+ 2. Run the following command in your terminal:
94
+ ```bash
95
+ docker run -d -p 8501:8501 iwatkot/maps4fs
96
+ ```
97
+ 3. Open your browser and go to [http://localhost:8501](http://localhost:8501).
98
+ 4. Fill in the required fields and click on the `Generate` button.
99
+ 5. When the map is generated click on the `Download` button to get the map.
100
+
101
+ ![WebUI](https://github.com/user-attachments/assets/b80c458b-29ea-4790-a640-8fa3b5550610)
102
+
103
+ ## Settings
104
+ Advanced settings are available in the tool's UI under the **Advanced Settings** tab. Here's the list of them:
105
+ - `max_height` - the maximum height of the map. The default value is 400. Select smaller values for plain-like maps and bigger values for mountainous maps. You may need to experiment with this value to get the desired result.
106
+ - `blur_seed` - the seed for the blur algorithm. The default value is 5, which means 5 meters. The bigger the value, the smoother the map will be. The smaller the value, the more detailed the map will be. Keep in mind that for some regions, where terrain is bumpy, disabling the blur algorithm may lead to a very rough map. So, I recommend leaving this value as it is.
107
+
108
+ ## Features
109
+ - Allows to enter a location by lat and lon (e.g. from Google Maps).
110
+ - Allows to select a size of the map (2x2, 4x4, 8x8 km, 16x16 km).
111
+ - Generates a map template (check the list of supported objects in [this section](#supported-objects)).
112
+ - Generates a height map.
113
+
114
+ ## Supported objects
115
+ The project is based on the [OpenStreetMap](https://www.openstreetmap.org/) data. So, refer to [this page](https://wiki.openstreetmap.org/wiki/Map_Features) to understand the list below.
116
+ - "building": True
117
+ - "highway": ["motorway", "trunk", "primary"]
118
+ - "highway": ["secondary", "tertiary", "road"]
119
+ - "highway": ["unclassified", "residential", "track"]
120
+ - "natural": "grassland"
121
+ - "landuse": "farmland"
122
+ - "natural": ["water"]
123
+ - "waterway": True
124
+ - "natural": ["wood", "tree_row"]
125
+
126
+ The list will be updated as the project develops.
127
+
128
+ ## Info sequence
129
+ The script will also generate the `generation_info.json` file in the `output` folder. It contains the following keys: <br>
130
+ `"coordinates"` - the coordinates of the map center which you entered,<br>
131
+ `"bbox"` - the bounding box of the map in lat and lon,<br>
132
+ `"distance"` - the size of the map in meters,<br>
133
+ `"minimum_x"` - the minimum x coordinate of the map (UTM projection),<br>
134
+ `"minimum_y"` - the minimum y coordinate of the map (UTM projection),<br>
135
+ `"maximum_x"` - the maximum x coordinate of the map (UTM projection),<br>
136
+ `"maximum_y"` - the maximum y coordinate of the map (UTM projection),<br>
137
+ `"height"` - the height of the map in meters (it won't be equal to the distance since the Earth is not flat, sorry flat-earthers),<br>
138
+ `"width"` - the width of the map in meters,<br>
139
+ `"height_coef"` - since we need a texture of exact size, the height of the map is multiplied by this coefficient,<br>
140
+ `"width_coef"` - same as above but for the width,<br>
141
+ `"tile_name"` - the name of the SRTM tile which was used to generate the height map, e.g. "N52E013"<br>
142
+
143
+ You can use this information to adjust some other sources of data to the map, e.g. textures, height maps, etc.
144
+
145
+ ## Bugs and feature requests
146
+ If you find a bug or have an idea for a new feature, please create an issue [here](https://github.com/iwatkot/maps4fs/issues) or contact me directly on [Telegram](https://t.me/iwatkot).<br>
147
+ ℹ️ Please, don't bother me if the Telegram bot is down. As I said before this is related to the hosting limitations, if you want you can always run the tool locally or support the project by donating, so maybe I'll be able to afford better hosting.
@@ -0,0 +1,14 @@
1
+ maps4fs/__init__.py,sha256=da4jmND2Ths9AffnkAKgzLHNkvKFOc_l21gJisPXqWY,155
2
+ maps4fs/logger.py,sha256=CneeHxQywjNUJXqQrUUSeiDxu95FfrfyK_Si1v0gMZ8,1477
3
+ maps4fs/generator/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
4
+ maps4fs/generator/component.py,sha256=UmR6Gbs-uDld7897q_9hOOzqBXaIYD8svmw_a3IgC5g,1761
5
+ maps4fs/generator/config.py,sha256=3X9E6luYh0dBlYcGvE4Exzp-ShAMCFjGB_8SK3qPBtM,1760
6
+ maps4fs/generator/dem.py,sha256=_1d_TPMOGBgl2-R_CRMbKumzxCbQyb-hcpqsElhYbQ4,8546
7
+ maps4fs/generator/game.py,sha256=IyXjNEC5epJmDdqjsrl4wKL85T1F23km73pUkBiuDWU,4468
8
+ maps4fs/generator/map.py,sha256=Y7ERUB6ivxJilDAjE9UD0-vl0SKtzj6J2f8QPXM6i48,3712
9
+ maps4fs/generator/texture.py,sha256=BUDDYIASk7lhYhJpEjZUTccKUI1GIfJIcPRgIgiUHAE,17938
10
+ maps4fs-0.6.0.dist-info/LICENSE.md,sha256=-JY0v7p3dwXze61EbYiK7YEJ2aKrjaFZ8y2xYEOrmRY,1068
11
+ maps4fs-0.6.0.dist-info/METADATA,sha256=4C_CBjobE8SIELoDlqjpcXEMMWZIMbf9YmM_VyBEp-Y,8937
12
+ maps4fs-0.6.0.dist-info/WHEEL,sha256=a7TGlA-5DaHMRrarXjVbQagU3Man_dCnGIWMJr5kRWo,91
13
+ maps4fs-0.6.0.dist-info/top_level.txt,sha256=Ue9DSRlejRQRCaJueB0uLcKrWwsEq9zezfv5dI5mV1M,8
14
+ maps4fs-0.6.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (75.4.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ maps4fs