maps4fs 1.1.9__tar.gz → 1.2.1__tar.gz
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.
Potentially problematic release.
This version of maps4fs might be problematic. Click here for more details.
- {maps4fs-1.1.9 → maps4fs-1.2.1}/PKG-INFO +7 -1
- {maps4fs-1.1.9 → maps4fs-1.2.1}/README.md +6 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs/generator/background.py +1 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs/generator/component.py +4 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs/generator/game.py +15 -0
- maps4fs-1.2.1/maps4fs/generator/grle.py +389 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs/generator/i3d.py +163 -1
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs/generator/map.py +16 -1
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs/generator/texture.py +28 -8
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs.egg-info/PKG-INFO +7 -1
- {maps4fs-1.1.9 → maps4fs-1.2.1}/pyproject.toml +1 -1
- maps4fs-1.1.9/maps4fs/generator/grle.py +0 -186
- {maps4fs-1.1.9 → maps4fs-1.2.1}/LICENSE.md +0 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs/__init__.py +0 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs/generator/__init__.py +0 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs/generator/config.py +0 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs/generator/dem.py +0 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs/generator/qgis.py +0 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs/logger.py +0 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs/toolbox/__init__.py +0 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs/toolbox/background.py +0 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs/toolbox/dem.py +0 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs.egg-info/SOURCES.txt +0 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs.egg-info/dependency_links.txt +0 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs.egg-info/requires.txt +0 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/maps4fs.egg-info/top_level.txt +0 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/setup.cfg +0 -0
- {maps4fs-1.1.9 → maps4fs-1.2.1}/tests/test_generator.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: maps4fs
|
3
|
-
Version: 1.1
|
3
|
+
Version: 1.2.1
|
4
4
|
Summary: Generate map templates for Farming Simulator from real places.
|
5
5
|
Author-email: iwatkot <iwatkot@gmail.com>
|
6
6
|
License: MIT License
|
@@ -68,6 +68,8 @@ Requires-Dist: pympler
|
|
68
68
|
🔄 Support map rotation 🆕<br>
|
69
69
|
🌾 Automatically generates fields 🆕<br>
|
70
70
|
🌽 Automatically generates farmlands 🆕<br>
|
71
|
+
🌿 Automatically generates decorative foliage 🆕<br>
|
72
|
+
🌲 Automatically generates forests 🆕<br>
|
71
73
|
🌍 Based on real-world data from OpenStreetMap<br>
|
72
74
|
🏞️ Generates height map using SRTM dataset<br>
|
73
75
|
📦 Provides a ready-to-use map template for the Giants Editor<br>
|
@@ -84,6 +86,10 @@ Requires-Dist: pympler
|
|
84
86
|
🛰️ Realistic background terrain with satellite images.<br><br>
|
85
87
|
<img src="https://github.com/user-attachments/assets/6e3c0e99-2cce-46ac-82db-5cb60bba7a30"><br>
|
86
88
|
📐 Perfectly aligned background terrain.<br><br>
|
89
|
+
<img src="https://github.com/user-attachments/assets/5764b2ec-e626-426f-9f5d-beb12ba95133"><br>
|
90
|
+
🌿 Automatically generates decorative foliage.<br><br>
|
91
|
+
<img src="https://github.com/user-attachments/assets/27a5e541-a9f5-4504-b8d2-64aae9fb3e52"><br>
|
92
|
+
🌲 Automatically generates forests.<br><br>
|
87
93
|
<img src="https://github.com/user-attachments/assets/80e5923c-22c7-4dc0-8906-680902511f3a"><br>
|
88
94
|
🗒️ True-to-life blueprints for fast and precise modding.<br><br>
|
89
95
|
<img width="480" src="https://github.com/user-attachments/assets/1a8802d2-6a3b-4bfa-af2b-7c09478e199b"><br>
|
@@ -43,6 +43,8 @@
|
|
43
43
|
🔄 Support map rotation 🆕<br>
|
44
44
|
🌾 Automatically generates fields 🆕<br>
|
45
45
|
🌽 Automatically generates farmlands 🆕<br>
|
46
|
+
🌿 Automatically generates decorative foliage 🆕<br>
|
47
|
+
🌲 Automatically generates forests 🆕<br>
|
46
48
|
🌍 Based on real-world data from OpenStreetMap<br>
|
47
49
|
🏞️ Generates height map using SRTM dataset<br>
|
48
50
|
📦 Provides a ready-to-use map template for the Giants Editor<br>
|
@@ -59,6 +61,10 @@
|
|
59
61
|
🛰️ Realistic background terrain with satellite images.<br><br>
|
60
62
|
<img src="https://github.com/user-attachments/assets/6e3c0e99-2cce-46ac-82db-5cb60bba7a30"><br>
|
61
63
|
📐 Perfectly aligned background terrain.<br><br>
|
64
|
+
<img src="https://github.com/user-attachments/assets/5764b2ec-e626-426f-9f5d-beb12ba95133"><br>
|
65
|
+
🌿 Automatically generates decorative foliage.<br><br>
|
66
|
+
<img src="https://github.com/user-attachments/assets/27a5e541-a9f5-4504-b8d2-64aae9fb3e52"><br>
|
67
|
+
🌲 Automatically generates forests.<br><br>
|
62
68
|
<img src="https://github.com/user-attachments/assets/80e5923c-22c7-4dc0-8906-680902511f3a"><br>
|
63
69
|
🗒️ True-to-life blueprints for fast and precise modding.<br><br>
|
64
70
|
<img width="480" src="https://github.com/user-attachments/assets/1a8802d2-6a3b-4bfa-af2b-7c09478e199b"><br>
|
@@ -17,6 +17,7 @@ from maps4fs.generator.qgis import save_scripts
|
|
17
17
|
|
18
18
|
if TYPE_CHECKING:
|
19
19
|
from maps4fs.generator.game import Game
|
20
|
+
from maps4fs.generator.map import Map
|
20
21
|
|
21
22
|
|
22
23
|
# pylint: disable=R0801, R0903, R0902, R0904
|
@@ -25,6 +26,7 @@ class Component:
|
|
25
26
|
|
26
27
|
Arguments:
|
27
28
|
game (Game): The game instance for which the map is generated.
|
29
|
+
map (Map): The map instance for which the component is generated.
|
28
30
|
coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
|
29
31
|
map_size (int): The size of the map in pixels.
|
30
32
|
map_rotated_size (int): The size of the map in pixels after rotation.
|
@@ -37,6 +39,7 @@ class Component:
|
|
37
39
|
def __init__(
|
38
40
|
self,
|
39
41
|
game: Game,
|
42
|
+
map: Map, # pylint: disable=W0622
|
40
43
|
coordinates: tuple[float, float],
|
41
44
|
map_size: int,
|
42
45
|
map_rotated_size: int,
|
@@ -46,6 +49,7 @@ class Component:
|
|
46
49
|
**kwargs, # pylint: disable=W0613, R0913, R0917
|
47
50
|
):
|
48
51
|
self.game = game
|
52
|
+
self.map = map
|
49
53
|
self.coordinates = coordinates
|
50
54
|
self.map_size = map_size
|
51
55
|
self.map_rotated_size = map_rotated_size
|
@@ -36,6 +36,7 @@ class Game:
|
|
36
36
|
_map_template_path: str | None = None
|
37
37
|
_texture_schema: str | None = None
|
38
38
|
_grle_schema: str | None = None
|
39
|
+
_tree_schema: str | None = None
|
39
40
|
|
40
41
|
# Order matters! Some components depend on others.
|
41
42
|
components = [Texture, I3d, GRLE, Background, Config]
|
@@ -109,6 +110,19 @@ class Game:
|
|
109
110
|
raise ValueError("GRLE layers schema path not set.")
|
110
111
|
return self._grle_schema
|
111
112
|
|
113
|
+
@property
|
114
|
+
def tree_schema(self) -> str:
|
115
|
+
"""Returns the path to the tree layers schema file.
|
116
|
+
|
117
|
+
Raises:
|
118
|
+
ValueError: If the tree layers schema path is not set.
|
119
|
+
|
120
|
+
Returns:
|
121
|
+
str: The path to the tree layers schema file."""
|
122
|
+
if not self._tree_schema:
|
123
|
+
raise ValueError("Tree layers schema path not set.")
|
124
|
+
return self._tree_schema
|
125
|
+
|
112
126
|
def dem_file_path(self, map_directory: str) -> str:
|
113
127
|
"""Returns the path to the DEM file.
|
114
128
|
|
@@ -187,6 +201,7 @@ class FS25(Game):
|
|
187
201
|
_map_template_path = os.path.join(working_directory, "data", "fs25-map-template.zip")
|
188
202
|
_texture_schema = os.path.join(working_directory, "data", "fs25-texture-schema.json")
|
189
203
|
_grle_schema = os.path.join(working_directory, "data", "fs25-grle-schema.json")
|
204
|
+
_tree_schema = os.path.join(working_directory, "data", "fs25-tree-schema.json")
|
190
205
|
|
191
206
|
def dem_file_path(self, map_directory: str) -> str:
|
192
207
|
"""Returns the path to the DEM file.
|
@@ -0,0 +1,389 @@
|
|
1
|
+
"""This module contains the GRLE class for generating InfoLayer PNG files based on GRLE schema."""
|
2
|
+
|
3
|
+
import json
|
4
|
+
import os
|
5
|
+
from random import choice, randint
|
6
|
+
from xml.etree import ElementTree as ET
|
7
|
+
|
8
|
+
import cv2
|
9
|
+
import numpy as np
|
10
|
+
from shapely.geometry import Polygon # type: ignore
|
11
|
+
|
12
|
+
from maps4fs.generator.component import Component
|
13
|
+
from maps4fs.generator.texture import Texture
|
14
|
+
|
15
|
+
ISLAND_SIZE_MIN = 10
|
16
|
+
ISLAND_SIZE_MAX = 200
|
17
|
+
ISLAND_DISTORTION = 0.3
|
18
|
+
ISLAND_VERTEX_COUNT = 30
|
19
|
+
ISLAND_ROUNDING_RADIUS = 15
|
20
|
+
|
21
|
+
|
22
|
+
# pylint: disable=W0223
|
23
|
+
class GRLE(Component):
|
24
|
+
"""Component for to generate InfoLayer PNG files based on GRLE schema.
|
25
|
+
|
26
|
+
Arguments:
|
27
|
+
game (Game): The game instance for which the map is generated.
|
28
|
+
coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
|
29
|
+
map_size (int): The size of the map in pixels.
|
30
|
+
map_rotated_size (int): The size of the map in pixels after rotation.
|
31
|
+
rotation (int): The rotation angle of the map.
|
32
|
+
map_directory (str): The directory where the map files are stored.
|
33
|
+
logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
|
34
|
+
info, warning. If not provided, default logging will be used.
|
35
|
+
"""
|
36
|
+
|
37
|
+
_grle_schema: dict[str, float | int | str] | None = None
|
38
|
+
|
39
|
+
def preprocess(self) -> None:
|
40
|
+
"""Gets the path to the map I3D file from the game instance and saves it to the instance
|
41
|
+
attribute. If the game does not support I3D files, the attribute is set to None."""
|
42
|
+
|
43
|
+
self.farmland_margin = self.kwargs.get("farmland_margin", 0)
|
44
|
+
|
45
|
+
try:
|
46
|
+
grle_schema_path = self.game.grle_schema
|
47
|
+
except ValueError:
|
48
|
+
self.logger.info("GRLE schema processing is not implemented for this game.")
|
49
|
+
return
|
50
|
+
|
51
|
+
try:
|
52
|
+
with open(grle_schema_path, "r", encoding="utf-8") as file:
|
53
|
+
self._grle_schema = json.load(file)
|
54
|
+
self.logger.debug("GRLE schema loaded from: %s.", grle_schema_path)
|
55
|
+
except (json.JSONDecodeError, FileNotFoundError) as error:
|
56
|
+
self.logger.error("Error loading GRLE schema from %s: %s.", grle_schema_path, error)
|
57
|
+
self._grle_schema = None
|
58
|
+
|
59
|
+
def process(self) -> None:
|
60
|
+
"""Generates InfoLayer PNG files based on the GRLE schema."""
|
61
|
+
if not self._grle_schema:
|
62
|
+
self.logger.info("GRLE schema is not obtained, skipping the processing.")
|
63
|
+
return
|
64
|
+
|
65
|
+
for info_layer in self._grle_schema:
|
66
|
+
if isinstance(info_layer, dict):
|
67
|
+
file_path = os.path.join(
|
68
|
+
self.game.weights_dir_path(self.map_directory), info_layer["name"]
|
69
|
+
)
|
70
|
+
|
71
|
+
height = int(self.map_size * info_layer["height_multiplier"])
|
72
|
+
width = int(self.map_size * info_layer["width_multiplier"])
|
73
|
+
channels = info_layer["channels"]
|
74
|
+
data_type = info_layer["data_type"]
|
75
|
+
|
76
|
+
# Create the InfoLayer PNG file with zeros.
|
77
|
+
if channels == 1:
|
78
|
+
info_layer_data = np.zeros((height, width), dtype=data_type)
|
79
|
+
else:
|
80
|
+
info_layer_data = np.zeros((height, width, channels), dtype=data_type)
|
81
|
+
self.logger.debug("Shape of %s: %s.", info_layer["name"], info_layer_data.shape)
|
82
|
+
cv2.imwrite(file_path, info_layer_data) # pylint: disable=no-member
|
83
|
+
self.logger.debug("InfoLayer PNG file %s created.", file_path)
|
84
|
+
else:
|
85
|
+
self.logger.warning("Invalid InfoLayer schema: %s.", info_layer)
|
86
|
+
|
87
|
+
self._add_farmlands()
|
88
|
+
if self.game.code == "FS25":
|
89
|
+
self.logger.info("Game is %s, plants will be added.", self.game.code)
|
90
|
+
self._add_plants()
|
91
|
+
else:
|
92
|
+
self.logger.warning("Adding plants it's not supported for the %s.", self.game.code)
|
93
|
+
|
94
|
+
def previews(self) -> list[str]:
|
95
|
+
"""Returns a list of paths to the preview images (empty list).
|
96
|
+
The component does not generate any preview images so it returns an empty list.
|
97
|
+
|
98
|
+
Returns:
|
99
|
+
list[str]: An empty list.
|
100
|
+
"""
|
101
|
+
return []
|
102
|
+
|
103
|
+
# pylint: disable=R0801, R0914
|
104
|
+
def _add_farmlands(self) -> None:
|
105
|
+
"""Adds farmlands to the InfoLayer PNG file."""
|
106
|
+
|
107
|
+
textures_info_layer_path = self.get_infolayer_path("textures")
|
108
|
+
if not textures_info_layer_path:
|
109
|
+
return
|
110
|
+
|
111
|
+
with open(textures_info_layer_path, "r", encoding="utf-8") as textures_info_layer_file:
|
112
|
+
textures_info_layer = json.load(textures_info_layer_file)
|
113
|
+
|
114
|
+
fields: list[list[tuple[int, int]]] | None = textures_info_layer.get("fields")
|
115
|
+
if not fields:
|
116
|
+
self.logger.warning("Fields data not found in textures info layer.")
|
117
|
+
return
|
118
|
+
|
119
|
+
self.logger.info("Found %s fields in textures info layer.", len(fields))
|
120
|
+
|
121
|
+
info_layer_farmlands_path = os.path.join(
|
122
|
+
self.game.weights_dir_path(self.map_directory), "infoLayer_farmlands.png"
|
123
|
+
)
|
124
|
+
|
125
|
+
self.logger.info(
|
126
|
+
"Adding farmlands to the InfoLayer PNG file: %s.", info_layer_farmlands_path
|
127
|
+
)
|
128
|
+
|
129
|
+
if not os.path.isfile(info_layer_farmlands_path):
|
130
|
+
self.logger.warning("InfoLayer PNG file %s not found.", info_layer_farmlands_path)
|
131
|
+
return
|
132
|
+
|
133
|
+
# pylint: disable=no-member
|
134
|
+
image = cv2.imread(info_layer_farmlands_path, cv2.IMREAD_UNCHANGED)
|
135
|
+
farmlands_xml_path = os.path.join(self.map_directory, "map/config/farmlands.xml")
|
136
|
+
if not os.path.isfile(farmlands_xml_path):
|
137
|
+
self.logger.warning("Farmlands XML file %s not found.", farmlands_xml_path)
|
138
|
+
return
|
139
|
+
|
140
|
+
tree = ET.parse(farmlands_xml_path)
|
141
|
+
farmlands_xml = tree.find("farmlands")
|
142
|
+
|
143
|
+
# Not using enumerate because in case of the error, we do not increment
|
144
|
+
# the farmland_id. So as a result we do not have a gap in the farmland IDs.
|
145
|
+
farmland_id = 1
|
146
|
+
|
147
|
+
for field in fields:
|
148
|
+
try:
|
149
|
+
fitted_field = self.fit_polygon_into_bounds(
|
150
|
+
field, self.farmland_margin, angle=self.rotation
|
151
|
+
)
|
152
|
+
except ValueError as e:
|
153
|
+
self.logger.warning(
|
154
|
+
"Farmland %s could not be fitted into the map bounds with error: %s",
|
155
|
+
farmland_id,
|
156
|
+
e,
|
157
|
+
)
|
158
|
+
continue
|
159
|
+
|
160
|
+
self.logger.debug("Fitted field %s contains %s points.", farmland_id, len(fitted_field))
|
161
|
+
|
162
|
+
field_np = np.array(fitted_field, np.int32)
|
163
|
+
field_np = field_np.reshape((-1, 1, 2))
|
164
|
+
|
165
|
+
self.logger.debug(
|
166
|
+
"Created a numpy array and reshaped it. Number of points: %s", len(field_np)
|
167
|
+
)
|
168
|
+
|
169
|
+
# Infolayer image is 1/2 of the size of the map image, that's why we need to divide
|
170
|
+
# the coordinates by 2.
|
171
|
+
field_np = field_np // 2
|
172
|
+
self.logger.debug("Divided the coordinates by 2.")
|
173
|
+
|
174
|
+
# pylint: disable=no-member
|
175
|
+
try:
|
176
|
+
cv2.fillPoly(image, [field_np], farmland_id) # type: ignore
|
177
|
+
except Exception as e: # pylint: disable=W0718
|
178
|
+
self.logger.warning(
|
179
|
+
"Farmland %s could not be added to the InfoLayer PNG file with error: %s",
|
180
|
+
farmland_id,
|
181
|
+
e,
|
182
|
+
)
|
183
|
+
continue
|
184
|
+
|
185
|
+
# Add the field to the farmlands XML.
|
186
|
+
farmland = ET.SubElement(farmlands_xml, "farmland") # type: ignore
|
187
|
+
farmland.set("id", str(farmland_id))
|
188
|
+
farmland.set("priceScale", "1")
|
189
|
+
farmland.set("npcName", "FORESTER")
|
190
|
+
|
191
|
+
farmland_id += 1
|
192
|
+
|
193
|
+
tree.write(farmlands_xml_path)
|
194
|
+
|
195
|
+
self.logger.info("Farmlands added to the farmlands XML file: %s.", farmlands_xml_path)
|
196
|
+
|
197
|
+
cv2.imwrite(info_layer_farmlands_path, image) # pylint: disable=no-member
|
198
|
+
self.logger.info(
|
199
|
+
"Farmlands added to the InfoLayer PNG file: %s.", info_layer_farmlands_path
|
200
|
+
)
|
201
|
+
|
202
|
+
# pylint: disable=R0915
|
203
|
+
def _add_plants(self) -> None:
|
204
|
+
"""Adds plants to the InfoLayer PNG file."""
|
205
|
+
# 1. Get the path to the densityMap_fruits.png.
|
206
|
+
# 2. Get the path to the base layer (grass).
|
207
|
+
# 3. Detect non-zero areas in the base layer (it's where the plants will be placed).
|
208
|
+
texture_component: Texture | None = self.map.get_component("Texture") # type: ignore
|
209
|
+
if not texture_component:
|
210
|
+
self.logger.warning("Texture component not found in the map.")
|
211
|
+
return
|
212
|
+
|
213
|
+
grass_layer = texture_component.get_layer_by_usage("grass")
|
214
|
+
if not grass_layer:
|
215
|
+
self.logger.warning("Grass layer not found in the texture component.")
|
216
|
+
return
|
217
|
+
|
218
|
+
weights_directory = self.game.weights_dir_path(self.map_directory)
|
219
|
+
grass_image_path = grass_layer.get_preview_or_path(weights_directory)
|
220
|
+
self.logger.debug("Grass image path: %s.", grass_image_path)
|
221
|
+
|
222
|
+
forest_layer = texture_component.get_layer_by_usage("forest")
|
223
|
+
forest_image = None
|
224
|
+
if forest_layer:
|
225
|
+
forest_image_path = forest_layer.get_preview_or_path(weights_directory)
|
226
|
+
self.logger.debug("Forest image path: %s.", forest_image_path)
|
227
|
+
if forest_image_path:
|
228
|
+
# pylint: disable=no-member
|
229
|
+
forest_image = cv2.imread(forest_image_path, cv2.IMREAD_UNCHANGED)
|
230
|
+
|
231
|
+
if not grass_image_path or not os.path.isfile(grass_image_path):
|
232
|
+
self.logger.warning("Base image not found in %s.", grass_image_path)
|
233
|
+
return
|
234
|
+
|
235
|
+
density_map_fruit_path = os.path.join(
|
236
|
+
self.game.weights_dir_path(self.map_directory), "densityMap_fruits.png"
|
237
|
+
)
|
238
|
+
|
239
|
+
self.logger.debug("Density map for fruits path: %s.", density_map_fruit_path)
|
240
|
+
|
241
|
+
if not os.path.isfile(density_map_fruit_path):
|
242
|
+
self.logger.warning("Density map for fruits not found in %s.", density_map_fruit_path)
|
243
|
+
return
|
244
|
+
|
245
|
+
# Single channeled 8-bit image, where non-zero values (255) are where the grass is.
|
246
|
+
grass_image = cv2.imread( # pylint: disable=no-member
|
247
|
+
grass_image_path, cv2.IMREAD_UNCHANGED # pylint: disable=no-member
|
248
|
+
)
|
249
|
+
|
250
|
+
# Density map of the fruits is 2X size of the base image, so we need to resize it.
|
251
|
+
# We'll resize the base image to make it bigger, so we can compare the values.
|
252
|
+
grass_image = cv2.resize( # pylint: disable=no-member
|
253
|
+
grass_image,
|
254
|
+
(grass_image.shape[1] * 2, grass_image.shape[0] * 2),
|
255
|
+
interpolation=cv2.INTER_NEAREST, # pylint: disable=no-member
|
256
|
+
)
|
257
|
+
if forest_image is not None:
|
258
|
+
forest_image = cv2.resize( # pylint: disable=no-member
|
259
|
+
forest_image,
|
260
|
+
(forest_image.shape[1] * 2, forest_image.shape[0] * 2),
|
261
|
+
interpolation=cv2.INTER_NEAREST, # pylint: disable=no-member
|
262
|
+
)
|
263
|
+
|
264
|
+
# Add non zero values from the forest image to the grass image.
|
265
|
+
grass_image[forest_image != 0] = 255
|
266
|
+
|
267
|
+
# B and G channels remain the same (zeros), while we change the R channel.
|
268
|
+
possible_R_values = [33, 65, 97, 129, 161, 193, 225] # pylint: disable=C0103
|
269
|
+
|
270
|
+
# 1st approach: Change the non zero values in the base image to 33 (for debug).
|
271
|
+
# And use the base image as R channel in the density map.
|
272
|
+
|
273
|
+
# pylint: disable=no-member
|
274
|
+
def create_island_of_plants(image: np.ndarray, count: int) -> np.ndarray:
|
275
|
+
"""Create an island of plants in the image.
|
276
|
+
|
277
|
+
Arguments:
|
278
|
+
image (np.ndarray): The image where the island of plants will be created.
|
279
|
+
count (int): The number of islands of plants to create.
|
280
|
+
|
281
|
+
Returns:
|
282
|
+
np.ndarray: The image with the islands of plants.
|
283
|
+
"""
|
284
|
+
for _ in range(count):
|
285
|
+
# Randomly choose the value for the island.
|
286
|
+
plant_value = choice(possible_R_values)
|
287
|
+
# Randomly choose the size of the island.
|
288
|
+
island_size = randint(ISLAND_SIZE_MIN, ISLAND_SIZE_MAX)
|
289
|
+
# Randomly choose the position of the island.
|
290
|
+
# x = np.random.randint(0, image.shape[1] - island_size)
|
291
|
+
# y = np.random.randint(0, image.shape[0] - island_size)
|
292
|
+
x = randint(0, image.shape[1] - island_size)
|
293
|
+
y = randint(0, image.shape[0] - island_size)
|
294
|
+
|
295
|
+
# Randomly choose the shape of the island.
|
296
|
+
# shapes = ["circle", "ellipse", "polygon"]
|
297
|
+
# shape = choice(shapes)
|
298
|
+
|
299
|
+
try:
|
300
|
+
polygon_points = get_rounded_polygon(
|
301
|
+
num_vertices=ISLAND_VERTEX_COUNT,
|
302
|
+
center=(x + island_size // 2, y + island_size // 2),
|
303
|
+
radius=island_size // 2,
|
304
|
+
rounding_radius=ISLAND_ROUNDING_RADIUS,
|
305
|
+
)
|
306
|
+
if not polygon_points:
|
307
|
+
continue
|
308
|
+
|
309
|
+
nodes = np.array(polygon_points, np.int32) # type: ignore
|
310
|
+
cv2.fillPoly(image, [nodes], plant_value) # type: ignore
|
311
|
+
except Exception: # pylint: disable=W0703
|
312
|
+
continue
|
313
|
+
|
314
|
+
return image
|
315
|
+
|
316
|
+
def get_rounded_polygon(
|
317
|
+
num_vertices: int, center: tuple[int, int], radius: int, rounding_radius: int
|
318
|
+
) -> list[tuple[int, int]] | None:
|
319
|
+
"""Get a randomly rounded polygon.
|
320
|
+
|
321
|
+
Arguments:
|
322
|
+
num_vertices (int): The number of vertices of the polygon.
|
323
|
+
center (tuple[int, int]): The center of the polygon.
|
324
|
+
radius (int): The radius of the polygon.
|
325
|
+
rounding_radius (int): The rounding radius of the polygon.
|
326
|
+
|
327
|
+
Returns:
|
328
|
+
list[tuple[int, int]] | None: The rounded polygon.
|
329
|
+
"""
|
330
|
+
angle_offset = np.pi / num_vertices
|
331
|
+
angles = np.linspace(0, 2 * np.pi, num_vertices, endpoint=False) + angle_offset
|
332
|
+
random_angles = angles + np.random.uniform(
|
333
|
+
-ISLAND_DISTORTION, ISLAND_DISTORTION, num_vertices
|
334
|
+
) # Add randomness to angles
|
335
|
+
random_radii = radius + np.random.uniform(
|
336
|
+
-radius * ISLAND_DISTORTION, radius * ISLAND_DISTORTION, num_vertices
|
337
|
+
) # Add randomness to radii
|
338
|
+
|
339
|
+
points = [
|
340
|
+
(center[0] + np.cos(a) * r, center[1] + np.sin(a) * r)
|
341
|
+
for a, r in zip(random_angles, random_radii)
|
342
|
+
]
|
343
|
+
polygon = Polygon(points)
|
344
|
+
buffered_polygon = polygon.buffer(rounding_radius, resolution=16)
|
345
|
+
rounded_polygon = list(buffered_polygon.exterior.coords)
|
346
|
+
if not rounded_polygon:
|
347
|
+
return None
|
348
|
+
return rounded_polygon
|
349
|
+
|
350
|
+
grass_image_copy = grass_image.copy()
|
351
|
+
if forest_image is not None:
|
352
|
+
# Add the forest layer to the base image, to merge the masks.
|
353
|
+
grass_image_copy[forest_image != 0] = 33
|
354
|
+
# Set all the non-zero values to 33.
|
355
|
+
grass_image_copy[grass_image != 0] = 33
|
356
|
+
|
357
|
+
# Add islands of plants to the base image.
|
358
|
+
island_count = self.map_size
|
359
|
+
self.logger.info("Adding %s islands of plants to the base image.", island_count)
|
360
|
+
grass_image_copy = create_island_of_plants(grass_image_copy, island_count)
|
361
|
+
self.logger.debug("Islands of plants added to the base image.")
|
362
|
+
|
363
|
+
# Sligtly reduce the size of the grass_image, that we'll use as mask.
|
364
|
+
kernel = np.ones((3, 3), np.uint8)
|
365
|
+
grass_image = cv2.erode(grass_image, kernel, iterations=1)
|
366
|
+
|
367
|
+
# Remove the values where the base image has zeros.
|
368
|
+
grass_image_copy[grass_image == 0] = 0
|
369
|
+
self.logger.debug("Removed the values where the base image has zeros.")
|
370
|
+
|
371
|
+
# Value of 33 represents the base grass plant.
|
372
|
+
# After painting it with base grass, we'll create multiple islands of different plants.
|
373
|
+
# On the final step, we'll remove all the values which in pixels
|
374
|
+
# where zerons in the original base image (so we don't paint grass where it should not be).
|
375
|
+
|
376
|
+
# Three channeled 8-bit image, where non-zero values are the
|
377
|
+
# different types of plants (only in the R channel).
|
378
|
+
density_map_fruits = cv2.imread(density_map_fruit_path, cv2.IMREAD_UNCHANGED)
|
379
|
+
self.logger.debug("Density map for fruits loaded, shape: %s.", density_map_fruits.shape)
|
380
|
+
|
381
|
+
# Put the updated base image as the B channel in the density map.
|
382
|
+
density_map_fruits[:, :, 0] = grass_image_copy
|
383
|
+
self.logger.debug("Updated base image added as the B channel in the density map.")
|
384
|
+
|
385
|
+
# Save the updated density map.
|
386
|
+
# Ensure that order of channels is correct because CV2 uses BGR and we need RGB.
|
387
|
+
density_map_fruits = cv2.cvtColor(density_map_fruits, cv2.COLOR_BGR2RGB)
|
388
|
+
cv2.imwrite(density_map_fruit_path, density_map_fruits)
|
389
|
+
self.logger.info("Updated density map for fruits saved in %s.", density_map_fruit_path)
|
@@ -4,15 +4,23 @@ from __future__ import annotations
|
|
4
4
|
|
5
5
|
import json
|
6
6
|
import os
|
7
|
+
from random import choice, randint, uniform
|
8
|
+
from typing import Generator
|
7
9
|
from xml.etree import ElementTree as ET
|
8
10
|
|
11
|
+
import cv2
|
12
|
+
import numpy as np
|
13
|
+
|
9
14
|
from maps4fs.generator.component import Component
|
15
|
+
from maps4fs.generator.texture import Texture
|
10
16
|
|
11
17
|
DEFAULT_HEIGHT_SCALE = 2000
|
12
18
|
DISPLACEMENT_LAYER_SIZE_FOR_BIG_MAPS = 32768
|
13
19
|
DEFAULT_MAX_LOD_DISTANCE = 10000
|
14
20
|
DEFAULT_MAX_LOD_OCCLUDER_DISTANCE = 10000
|
15
|
-
NODE_ID_STARTING_VALUE =
|
21
|
+
NODE_ID_STARTING_VALUE = 2000
|
22
|
+
TREE_NODE_ID_STARTING_VALUE = 4000
|
23
|
+
DEFAULT_FOREST_DENSITY = 10
|
16
24
|
|
17
25
|
|
18
26
|
# pylint: disable=R0903
|
@@ -44,10 +52,14 @@ class I3d(Component):
|
|
44
52
|
self.logger.info("I3D file processing is not implemented for this game.")
|
45
53
|
self._map_i3d_path = None
|
46
54
|
|
55
|
+
self.forest_density = self.kwargs.get("forest_density", DEFAULT_FOREST_DENSITY)
|
56
|
+
self.logger.info("Forest density: %s.", self.forest_density)
|
57
|
+
|
47
58
|
def process(self) -> None:
|
48
59
|
"""Updates the map I3D file with the default settings."""
|
49
60
|
self._update_i3d_file()
|
50
61
|
self._add_fields()
|
62
|
+
self._add_forests()
|
51
63
|
|
52
64
|
def _get_tree(self) -> ET.ElementTree | None:
|
53
65
|
"""Returns the ElementTree instance of the map I3D file."""
|
@@ -85,6 +97,21 @@ class I3d(Component):
|
|
85
97
|
|
86
98
|
self.logger.debug("TerrainTransformGroup element updated in I3D file.")
|
87
99
|
|
100
|
+
sun_elem = map_elem.find(".//Light[@name='sun']")
|
101
|
+
|
102
|
+
if sun_elem is not None:
|
103
|
+
self.logger.debug("Sun element found in I3D file.")
|
104
|
+
|
105
|
+
distance = self.map_size // 2
|
106
|
+
|
107
|
+
sun_elem.set("lastShadowMapSplitBboxMin", f"-{distance},-128,-{distance}")
|
108
|
+
sun_elem.set("lastShadowMapSplitBboxMax", f"{distance},148,{distance}")
|
109
|
+
|
110
|
+
self.logger.debug(
|
111
|
+
"Sun BBOX updated with half of the map size: %s.",
|
112
|
+
distance,
|
113
|
+
)
|
114
|
+
|
88
115
|
if self.map_size > 4096:
|
89
116
|
displacement_layer = terrain_elem.find(".//DisplacementLayer") # pylint: disable=W0631
|
90
117
|
|
@@ -301,3 +328,138 @@ class I3d(Component):
|
|
301
328
|
attribute_node.set("type", attr_type)
|
302
329
|
attribute_node.set("value", value)
|
303
330
|
return attribute_node
|
331
|
+
|
332
|
+
# pylint: disable=R0911
|
333
|
+
def _add_forests(self) -> None:
|
334
|
+
"""Adds forests to the map I3D file."""
|
335
|
+
try:
|
336
|
+
tree_schema_path = self.game.tree_schema
|
337
|
+
except ValueError:
|
338
|
+
self.logger.warning("Tree schema path not set for the Game %s.", self.game.code)
|
339
|
+
return
|
340
|
+
|
341
|
+
if not os.path.isfile(tree_schema_path):
|
342
|
+
self.logger.warning("Tree schema file was not found: %s.", tree_schema_path)
|
343
|
+
return
|
344
|
+
|
345
|
+
try:
|
346
|
+
with open(tree_schema_path, "r", encoding="utf-8") as tree_schema_file:
|
347
|
+
tree_schema: list[dict[str, str | int]] = json.load(tree_schema_file)
|
348
|
+
except json.JSONDecodeError as e:
|
349
|
+
self.logger.warning(
|
350
|
+
"Could not load tree schema from %s with error: %s", tree_schema_path, e
|
351
|
+
)
|
352
|
+
return
|
353
|
+
|
354
|
+
texture_component: Texture | None = self.map.get_component("Texture") # type: ignore
|
355
|
+
if not texture_component:
|
356
|
+
self.logger.warning("Texture component not found.")
|
357
|
+
return
|
358
|
+
|
359
|
+
forest_layer = texture_component.get_layer_by_usage("forest")
|
360
|
+
|
361
|
+
if not forest_layer:
|
362
|
+
self.logger.warning("Forest layer not found.")
|
363
|
+
return
|
364
|
+
|
365
|
+
weights_directory = self.game.weights_dir_path(self.map_directory)
|
366
|
+
forest_image_path = forest_layer.get_preview_or_path(weights_directory)
|
367
|
+
|
368
|
+
if not forest_image_path or not os.path.isfile(forest_image_path):
|
369
|
+
self.logger.warning("Forest image not found.")
|
370
|
+
return
|
371
|
+
|
372
|
+
tree = self._get_tree()
|
373
|
+
if tree is None:
|
374
|
+
return
|
375
|
+
|
376
|
+
# Find the <Scene> element in the I3D file.
|
377
|
+
root = tree.getroot()
|
378
|
+
scene_node = root.find(".//Scene")
|
379
|
+
if scene_node is None:
|
380
|
+
self.logger.warning("Scene element not found in I3D file.")
|
381
|
+
return
|
382
|
+
|
383
|
+
self.logger.debug("Scene element found in I3D file, starting to add forests.")
|
384
|
+
|
385
|
+
node_id = TREE_NODE_ID_STARTING_VALUE
|
386
|
+
|
387
|
+
# Create <TransformGroup name="trees" translation="0 400 0" nodeId="{node_id}"> element.
|
388
|
+
trees_node = ET.Element("TransformGroup")
|
389
|
+
trees_node.set("name", "trees")
|
390
|
+
trees_node.set("translation", "0 400 0")
|
391
|
+
trees_node.set("nodeId", str(node_id))
|
392
|
+
node_id += 1
|
393
|
+
|
394
|
+
# pylint: disable=no-member
|
395
|
+
forest_image = cv2.imread(forest_image_path, cv2.IMREAD_UNCHANGED)
|
396
|
+
|
397
|
+
tree_count = 0
|
398
|
+
for x, y in self.non_empty_pixels(forest_image, step=self.forest_density):
|
399
|
+
xcs, ycs = self.top_left_coordinates_to_center((x, y))
|
400
|
+
node_id += 1
|
401
|
+
|
402
|
+
rotation = randint(-180, 180)
|
403
|
+
xcs, ycs = self.randomize_coordinates((xcs, ycs), self.forest_density) # type: ignore
|
404
|
+
|
405
|
+
random_tree = choice(tree_schema)
|
406
|
+
tree_name = random_tree["name"]
|
407
|
+
tree_id = random_tree["reference_id"]
|
408
|
+
|
409
|
+
reference_node = ET.Element("ReferenceNode")
|
410
|
+
reference_node.set("name", tree_name) # type: ignore
|
411
|
+
reference_node.set("translation", f"{xcs} 0 {ycs}")
|
412
|
+
reference_node.set("rotation", f"0 {rotation} 0")
|
413
|
+
reference_node.set("referenceId", str(tree_id))
|
414
|
+
reference_node.set("nodeId", str(node_id))
|
415
|
+
|
416
|
+
trees_node.append(reference_node)
|
417
|
+
tree_count += 1
|
418
|
+
|
419
|
+
scene_node.append(trees_node)
|
420
|
+
self.logger.info("Added %s trees to the I3D file.", tree_count)
|
421
|
+
|
422
|
+
tree.write(self._map_i3d_path) # type: ignore
|
423
|
+
self.logger.info("Map I3D file saved to: %s.", self._map_i3d_path)
|
424
|
+
|
425
|
+
@staticmethod
|
426
|
+
def randomize_coordinates(coordinates: tuple[int, int], density: int) -> tuple[float, float]:
|
427
|
+
"""Randomizes the coordinates of the point with the given density.
|
428
|
+
|
429
|
+
Arguments:
|
430
|
+
coordinates (tuple[int, int]): The coordinates of the point.
|
431
|
+
density (int): The density of the randomization.
|
432
|
+
|
433
|
+
Returns:
|
434
|
+
tuple[float, float]: The randomized coordinates of the point.
|
435
|
+
"""
|
436
|
+
MAXIMUM_RELATIVE_SHIFT = 0.2 # pylint: disable=C0103
|
437
|
+
shift_range = density * MAXIMUM_RELATIVE_SHIFT
|
438
|
+
|
439
|
+
x_shift = uniform(-shift_range, shift_range)
|
440
|
+
y_shift = uniform(-shift_range, shift_range)
|
441
|
+
|
442
|
+
x, y = coordinates
|
443
|
+
x += x_shift # type: ignore
|
444
|
+
y += y_shift # type: ignore
|
445
|
+
|
446
|
+
return x, y
|
447
|
+
|
448
|
+
@staticmethod
|
449
|
+
def non_empty_pixels(
|
450
|
+
image: np.ndarray, step: int = 1
|
451
|
+
) -> Generator[tuple[int, int], None, None]:
|
452
|
+
"""Receives numpy array, which represents single-channeled image of uint8 type.
|
453
|
+
Yield coordinates of non-empty pixels (pixels with value greater than 0).
|
454
|
+
|
455
|
+
Arguments:
|
456
|
+
image (np.ndarray): The image to get non-empty pixels from.
|
457
|
+
step (int, optional): The step to iterate through the image. Defaults to 1.
|
458
|
+
|
459
|
+
Yields:
|
460
|
+
tuple[int, int]: The coordinates of non-empty pixels.
|
461
|
+
"""
|
462
|
+
for y, row in enumerate(image[::step]):
|
463
|
+
for x, value in enumerate(row[::step]):
|
464
|
+
if value > 0:
|
465
|
+
yield x * step, y * step
|
@@ -74,6 +74,7 @@ class Map:
|
|
74
74
|
for game_component in self.game.components:
|
75
75
|
component = game_component(
|
76
76
|
self.game,
|
77
|
+
self,
|
77
78
|
self.coordinates,
|
78
79
|
self.size,
|
79
80
|
self.rotated_size,
|
@@ -82,6 +83,7 @@ class Map:
|
|
82
83
|
self.logger,
|
83
84
|
**self.kwargs,
|
84
85
|
)
|
86
|
+
self.components.append(component)
|
85
87
|
|
86
88
|
yield component.__class__.__name__
|
87
89
|
|
@@ -104,7 +106,20 @@ class Map:
|
|
104
106
|
e,
|
105
107
|
)
|
106
108
|
raise e
|
107
|
-
|
109
|
+
|
110
|
+
def get_component(self, component_name: str) -> Component | None:
|
111
|
+
"""Get component by name.
|
112
|
+
|
113
|
+
Arguments:
|
114
|
+
component_name (str): Name of the component.
|
115
|
+
|
116
|
+
Returns:
|
117
|
+
Component | None: Component instance or None if not found.
|
118
|
+
"""
|
119
|
+
for component in self.components:
|
120
|
+
if component.__class__.__name__ == component_name:
|
121
|
+
return component
|
122
|
+
return None
|
108
123
|
|
109
124
|
def previews(self) -> list[str]:
|
110
125
|
"""Get list of preview images.
|
@@ -63,6 +63,7 @@ class Texture(Component):
|
|
63
63
|
exclude_weight: bool = False,
|
64
64
|
priority: int | None = None,
|
65
65
|
info_layer: str | None = None,
|
66
|
+
usage: str | None = None,
|
66
67
|
):
|
67
68
|
self.name = name
|
68
69
|
self.count = count
|
@@ -72,6 +73,7 @@ class Texture(Component):
|
|
72
73
|
self.exclude_weight = exclude_weight
|
73
74
|
self.priority = priority
|
74
75
|
self.info_layer = info_layer
|
76
|
+
self.usage = usage
|
75
77
|
|
76
78
|
def to_json(self) -> dict[str, str | list[str] | bool]: # type: ignore
|
77
79
|
"""Returns dictionary with layer data.
|
@@ -87,6 +89,7 @@ class Texture(Component):
|
|
87
89
|
"exclude_weight": self.exclude_weight,
|
88
90
|
"priority": self.priority,
|
89
91
|
"info_layer": self.info_layer,
|
92
|
+
"usage": self.usage,
|
90
93
|
}
|
91
94
|
|
92
95
|
data = {k: v for k, v in data.items() if v is not None}
|
@@ -117,29 +120,29 @@ class Texture(Component):
|
|
117
120
|
weight_postfix = "_weight" if not self.exclude_weight else ""
|
118
121
|
return os.path.join(weights_directory, f"{self.name}{idx}{weight_postfix}.png")
|
119
122
|
|
120
|
-
def path_preview(self,
|
123
|
+
def path_preview(self, weights_directory: str) -> str:
|
121
124
|
"""Returns path to the preview of the first texture of the layer.
|
122
125
|
|
123
126
|
Arguments:
|
124
|
-
|
127
|
+
weights_directory (str): Path to the directory with weights.
|
125
128
|
|
126
129
|
Returns:
|
127
130
|
str: Path to the preview.
|
128
131
|
"""
|
129
|
-
return self.path(
|
132
|
+
return self.path(weights_directory).replace(".png", "_preview.png")
|
130
133
|
|
131
|
-
def get_preview_or_path(self,
|
134
|
+
def get_preview_or_path(self, weights_directory: str) -> str:
|
132
135
|
"""Returns path to the preview of the first texture of the layer if it exists,
|
133
136
|
otherwise returns path to the texture.
|
134
137
|
|
135
138
|
Arguments:
|
136
|
-
|
139
|
+
weights_directory (str): Path to the directory with weights.
|
137
140
|
|
138
141
|
Returns:
|
139
142
|
str: Path to the preview or texture.
|
140
143
|
"""
|
141
|
-
preview_path = self.path_preview(
|
142
|
-
return preview_path if os.path.isfile(preview_path) else self.path(
|
144
|
+
preview_path = self.path_preview(weights_directory)
|
145
|
+
return preview_path if os.path.isfile(preview_path) else self.path(weights_directory)
|
143
146
|
|
144
147
|
def paths(self, weights_directory: str) -> list[str]:
|
145
148
|
"""Returns a list of paths to the textures of the layer.
|
@@ -212,6 +215,20 @@ class Texture(Component):
|
|
212
215
|
return layer
|
213
216
|
return None
|
214
217
|
|
218
|
+
def get_layer_by_usage(self, usage: str) -> Layer | None:
|
219
|
+
"""Returns layer by usage.
|
220
|
+
|
221
|
+
Arguments:
|
222
|
+
usage (str): Usage of the layer.
|
223
|
+
|
224
|
+
Returns:
|
225
|
+
Layer | None: Layer.
|
226
|
+
"""
|
227
|
+
for layer in self.layers:
|
228
|
+
if layer.usage == usage:
|
229
|
+
return layer
|
230
|
+
return None
|
231
|
+
|
215
232
|
def process(self):
|
216
233
|
self._prepare_weights()
|
217
234
|
self._read_parameters()
|
@@ -287,7 +304,10 @@ class Texture(Component):
|
|
287
304
|
Arguments:
|
288
305
|
layer (Layer): Layer with textures and tags.
|
289
306
|
"""
|
290
|
-
|
307
|
+
if layer.tags is None:
|
308
|
+
size = (self.map_size, self.map_size)
|
309
|
+
else:
|
310
|
+
size = (self.map_rotated_size, self.map_rotated_size)
|
291
311
|
postfix = "_weight.png" if not layer.exclude_weight else ".png"
|
292
312
|
if layer.count == 0:
|
293
313
|
filepaths = [os.path.join(self._weights_dir, layer.name + postfix)]
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: maps4fs
|
3
|
-
Version: 1.1
|
3
|
+
Version: 1.2.1
|
4
4
|
Summary: Generate map templates for Farming Simulator from real places.
|
5
5
|
Author-email: iwatkot <iwatkot@gmail.com>
|
6
6
|
License: MIT License
|
@@ -68,6 +68,8 @@ Requires-Dist: pympler
|
|
68
68
|
🔄 Support map rotation 🆕<br>
|
69
69
|
🌾 Automatically generates fields 🆕<br>
|
70
70
|
🌽 Automatically generates farmlands 🆕<br>
|
71
|
+
🌿 Automatically generates decorative foliage 🆕<br>
|
72
|
+
🌲 Automatically generates forests 🆕<br>
|
71
73
|
🌍 Based on real-world data from OpenStreetMap<br>
|
72
74
|
🏞️ Generates height map using SRTM dataset<br>
|
73
75
|
📦 Provides a ready-to-use map template for the Giants Editor<br>
|
@@ -84,6 +86,10 @@ Requires-Dist: pympler
|
|
84
86
|
🛰️ Realistic background terrain with satellite images.<br><br>
|
85
87
|
<img src="https://github.com/user-attachments/assets/6e3c0e99-2cce-46ac-82db-5cb60bba7a30"><br>
|
86
88
|
📐 Perfectly aligned background terrain.<br><br>
|
89
|
+
<img src="https://github.com/user-attachments/assets/5764b2ec-e626-426f-9f5d-beb12ba95133"><br>
|
90
|
+
🌿 Automatically generates decorative foliage.<br><br>
|
91
|
+
<img src="https://github.com/user-attachments/assets/27a5e541-a9f5-4504-b8d2-64aae9fb3e52"><br>
|
92
|
+
🌲 Automatically generates forests.<br><br>
|
87
93
|
<img src="https://github.com/user-attachments/assets/80e5923c-22c7-4dc0-8906-680902511f3a"><br>
|
88
94
|
🗒️ True-to-life blueprints for fast and precise modding.<br><br>
|
89
95
|
<img width="480" src="https://github.com/user-attachments/assets/1a8802d2-6a3b-4bfa-af2b-7c09478e199b"><br>
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
4
4
|
|
5
5
|
[project]
|
6
6
|
name = "maps4fs"
|
7
|
-
version = "1.1
|
7
|
+
version = "1.2.1"
|
8
8
|
description = "Generate map templates for Farming Simulator from real places."
|
9
9
|
authors = [{name = "iwatkot", email = "iwatkot@gmail.com"}]
|
10
10
|
license = {text = "MIT License"}
|
@@ -1,186 +0,0 @@
|
|
1
|
-
"""This module contains the GRLE class for generating InfoLayer PNG files based on GRLE schema."""
|
2
|
-
|
3
|
-
import json
|
4
|
-
import os
|
5
|
-
from xml.etree import ElementTree as ET
|
6
|
-
|
7
|
-
import cv2
|
8
|
-
import numpy as np
|
9
|
-
|
10
|
-
from maps4fs.generator.component import Component
|
11
|
-
|
12
|
-
|
13
|
-
# pylint: disable=W0223
|
14
|
-
class GRLE(Component):
|
15
|
-
"""Component for to generate InfoLayer PNG files based on GRLE schema.
|
16
|
-
|
17
|
-
Arguments:
|
18
|
-
game (Game): The game instance for which the map is generated.
|
19
|
-
coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
|
20
|
-
map_size (int): The size of the map in pixels.
|
21
|
-
map_rotated_size (int): The size of the map in pixels after rotation.
|
22
|
-
rotation (int): The rotation angle of the map.
|
23
|
-
map_directory (str): The directory where the map files are stored.
|
24
|
-
logger (Any, optional): The logger to use. Must have at least three basic methods: debug,
|
25
|
-
info, warning. If not provided, default logging will be used.
|
26
|
-
"""
|
27
|
-
|
28
|
-
_grle_schema: dict[str, float | int | str] | None = None
|
29
|
-
|
30
|
-
def preprocess(self) -> None:
|
31
|
-
"""Gets the path to the map I3D file from the game instance and saves it to the instance
|
32
|
-
attribute. If the game does not support I3D files, the attribute is set to None."""
|
33
|
-
|
34
|
-
self.farmland_margin = self.kwargs.get("farmland_margin", 0)
|
35
|
-
|
36
|
-
try:
|
37
|
-
grle_schema_path = self.game.grle_schema
|
38
|
-
except ValueError:
|
39
|
-
self.logger.info("GRLE schema processing is not implemented for this game.")
|
40
|
-
return
|
41
|
-
|
42
|
-
try:
|
43
|
-
with open(grle_schema_path, "r", encoding="utf-8") as file:
|
44
|
-
self._grle_schema = json.load(file)
|
45
|
-
self.logger.debug("GRLE schema loaded from: %s.", grle_schema_path)
|
46
|
-
except (json.JSONDecodeError, FileNotFoundError) as error:
|
47
|
-
self.logger.error("Error loading GRLE schema from %s: %s.", grle_schema_path, error)
|
48
|
-
self._grle_schema = None
|
49
|
-
|
50
|
-
def process(self) -> None:
|
51
|
-
"""Generates InfoLayer PNG files based on the GRLE schema."""
|
52
|
-
if not self._grle_schema:
|
53
|
-
self.logger.info("GRLE schema is not obtained, skipping the processing.")
|
54
|
-
return
|
55
|
-
|
56
|
-
for info_layer in self._grle_schema:
|
57
|
-
if isinstance(info_layer, dict):
|
58
|
-
file_path = os.path.join(
|
59
|
-
self.game.weights_dir_path(self.map_directory), info_layer["name"]
|
60
|
-
)
|
61
|
-
|
62
|
-
height = int(self.map_size * info_layer["height_multiplier"])
|
63
|
-
width = int(self.map_size * info_layer["width_multiplier"])
|
64
|
-
channels = info_layer["channels"]
|
65
|
-
data_type = info_layer["data_type"]
|
66
|
-
|
67
|
-
# Create the InfoLayer PNG file with zeros.
|
68
|
-
if channels == 1:
|
69
|
-
info_layer_data = np.zeros((height, width), dtype=data_type)
|
70
|
-
else:
|
71
|
-
info_layer_data = np.zeros((height, width, channels), dtype=data_type)
|
72
|
-
self.logger.debug("Shape of %s: %s.", info_layer["name"], info_layer_data.shape)
|
73
|
-
cv2.imwrite(file_path, info_layer_data) # pylint: disable=no-member
|
74
|
-
self.logger.debug("InfoLayer PNG file %s created.", file_path)
|
75
|
-
else:
|
76
|
-
self.logger.warning("Invalid InfoLayer schema: %s.", info_layer)
|
77
|
-
|
78
|
-
self._add_farmlands()
|
79
|
-
|
80
|
-
def previews(self) -> list[str]:
|
81
|
-
"""Returns a list of paths to the preview images (empty list).
|
82
|
-
The component does not generate any preview images so it returns an empty list.
|
83
|
-
|
84
|
-
Returns:
|
85
|
-
list[str]: An empty list.
|
86
|
-
"""
|
87
|
-
return []
|
88
|
-
|
89
|
-
# pylint: disable=R0801, R0914
|
90
|
-
def _add_farmlands(self) -> None:
|
91
|
-
"""Adds farmlands to the InfoLayer PNG file."""
|
92
|
-
|
93
|
-
textures_info_layer_path = self.get_infolayer_path("textures")
|
94
|
-
if not textures_info_layer_path:
|
95
|
-
return
|
96
|
-
|
97
|
-
with open(textures_info_layer_path, "r", encoding="utf-8") as textures_info_layer_file:
|
98
|
-
textures_info_layer = json.load(textures_info_layer_file)
|
99
|
-
|
100
|
-
fields: list[list[tuple[int, int]]] | None = textures_info_layer.get("fields")
|
101
|
-
if not fields:
|
102
|
-
self.logger.warning("Fields data not found in textures info layer.")
|
103
|
-
return
|
104
|
-
|
105
|
-
self.logger.info("Found %s fields in textures info layer.", len(fields))
|
106
|
-
|
107
|
-
info_layer_farmlands_path = os.path.join(
|
108
|
-
self.game.weights_dir_path(self.map_directory), "infoLayer_farmlands.png"
|
109
|
-
)
|
110
|
-
|
111
|
-
self.logger.info(
|
112
|
-
"Adding farmlands to the InfoLayer PNG file: %s.", info_layer_farmlands_path
|
113
|
-
)
|
114
|
-
|
115
|
-
if not os.path.isfile(info_layer_farmlands_path):
|
116
|
-
self.logger.warning("InfoLayer PNG file %s not found.", info_layer_farmlands_path)
|
117
|
-
return
|
118
|
-
|
119
|
-
# pylint: disable=no-member
|
120
|
-
image = cv2.imread(info_layer_farmlands_path, cv2.IMREAD_UNCHANGED)
|
121
|
-
farmlands_xml_path = os.path.join(self.map_directory, "map/config/farmlands.xml")
|
122
|
-
if not os.path.isfile(farmlands_xml_path):
|
123
|
-
self.logger.warning("Farmlands XML file %s not found.", farmlands_xml_path)
|
124
|
-
return
|
125
|
-
|
126
|
-
tree = ET.parse(farmlands_xml_path)
|
127
|
-
farmlands_xml = tree.find("farmlands")
|
128
|
-
|
129
|
-
# Not using enumerate because in case of the error, we do not increment
|
130
|
-
# the farmland_id. So as a result we do not have a gap in the farmland IDs.
|
131
|
-
farmland_id = 1
|
132
|
-
|
133
|
-
for field in fields:
|
134
|
-
try:
|
135
|
-
fitted_field = self.fit_polygon_into_bounds(
|
136
|
-
field, self.farmland_margin, angle=self.rotation
|
137
|
-
)
|
138
|
-
except ValueError as e:
|
139
|
-
self.logger.warning(
|
140
|
-
"Farmland %s could not be fitted into the map bounds with error: %s",
|
141
|
-
farmland_id,
|
142
|
-
e,
|
143
|
-
)
|
144
|
-
continue
|
145
|
-
|
146
|
-
self.logger.debug("Fitted field %s contains %s points.", farmland_id, len(fitted_field))
|
147
|
-
|
148
|
-
field_np = np.array(fitted_field, np.int32)
|
149
|
-
field_np = field_np.reshape((-1, 1, 2))
|
150
|
-
|
151
|
-
self.logger.debug(
|
152
|
-
"Created a numpy array and reshaped it. Number of points: %s", len(field_np)
|
153
|
-
)
|
154
|
-
|
155
|
-
# Infolayer image is 1/2 of the size of the map image, that's why we need to divide
|
156
|
-
# the coordinates by 2.
|
157
|
-
field_np = field_np // 2
|
158
|
-
self.logger.debug("Divided the coordinates by 2.")
|
159
|
-
|
160
|
-
# pylint: disable=no-member
|
161
|
-
try:
|
162
|
-
cv2.fillPoly(image, [field_np], farmland_id) # type: ignore
|
163
|
-
except Exception as e: # pylint: disable=W0718
|
164
|
-
self.logger.warning(
|
165
|
-
"Farmland %s could not be added to the InfoLayer PNG file with error: %s",
|
166
|
-
farmland_id,
|
167
|
-
e,
|
168
|
-
)
|
169
|
-
continue
|
170
|
-
|
171
|
-
# Add the field to the farmlands XML.
|
172
|
-
farmland = ET.SubElement(farmlands_xml, "farmland") # type: ignore
|
173
|
-
farmland.set("id", str(farmland_id))
|
174
|
-
farmland.set("priceScale", "1")
|
175
|
-
farmland.set("npcName", "FORESTER")
|
176
|
-
|
177
|
-
farmland_id += 1
|
178
|
-
|
179
|
-
tree.write(farmlands_xml_path)
|
180
|
-
|
181
|
-
self.logger.info("Farmlands added to the farmlands XML file: %s.", farmlands_xml_path)
|
182
|
-
|
183
|
-
cv2.imwrite(info_layer_farmlands_path, image) # pylint: disable=no-member
|
184
|
-
self.logger.info(
|
185
|
-
"Farmlands added to the InfoLayer PNG file: %s.", info_layer_farmlands_path
|
186
|
-
)
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|
File without changes
|