maps4fs 1.4.1__py3-none-any.whl → 1.5.7__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.
@@ -0,0 +1,333 @@
1
+ """This module contains the DTMProvider class and its subclasses. DTMProvider class is used to
2
+ define different providers of digital terrain models (DTM) data. Each provider has its own URL
3
+ and specific settings for downloading and processing the data."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import gzip
8
+ import math
9
+ import os
10
+ import shutil
11
+ from typing import Type
12
+
13
+ import numpy as np
14
+ import osmnx as ox # type: ignore
15
+ import rasterio # type: ignore
16
+ import requests
17
+ from pydantic import BaseModel
18
+
19
+ from maps4fs.logger import Logger
20
+
21
+
22
+ class DTMProviderSettings(BaseModel):
23
+ """Base class for DTM provider settings models."""
24
+
25
+
26
+ class DTMProvider:
27
+ """Base class for DTM providers."""
28
+
29
+ _code: str | None = None
30
+ _name: str | None = None
31
+ _region: str | None = None
32
+ _icon: str | None = None
33
+ _resolution: float | None = None
34
+
35
+ _url: str | None = None
36
+
37
+ _author: str | None = None
38
+ _is_community: bool = False
39
+ _settings: Type[DTMProviderSettings] | None = None
40
+
41
+ _instructions: str | None = None
42
+
43
+ # pylint: disable=R0913, R0917
44
+ def __init__(
45
+ self,
46
+ coordinates: tuple[float, float],
47
+ user_settings: DTMProviderSettings | None,
48
+ size: int,
49
+ directory: str,
50
+ logger: Logger,
51
+ ):
52
+ self._coordinates = coordinates
53
+ self._user_settings = user_settings
54
+ self._size = size
55
+
56
+ if not self._code:
57
+ raise ValueError("Provider code must be defined.")
58
+ self._tile_directory = os.path.join(directory, self._code)
59
+ os.makedirs(self._tile_directory, exist_ok=True)
60
+
61
+ self.logger = logger
62
+
63
+ @property
64
+ def coordinates(self) -> tuple[float, float]:
65
+ """Coordinates of the center point of the DTM data.
66
+
67
+ Returns:
68
+ tuple: Latitude and longitude of the center point.
69
+ """
70
+ return self._coordinates
71
+
72
+ @property
73
+ def size(self) -> int:
74
+ """Size of the DTM data in meters.
75
+
76
+ Returns:
77
+ int: Size of the DTM data.
78
+ """
79
+ return self._size
80
+
81
+ @property
82
+ def url(self) -> str | None:
83
+ """URL of the provider."""
84
+ return self._url
85
+
86
+ def formatted_url(self, **kwargs) -> str:
87
+ """Formatted URL of the provider."""
88
+ if not self.url:
89
+ raise ValueError("URL must be defined.")
90
+ return self.url.format(**kwargs)
91
+
92
+ @classmethod
93
+ def author(cls) -> str | None:
94
+ """Author of the provider.
95
+
96
+ Returns:
97
+ str: Author of the provider.
98
+ """
99
+ return cls._author
100
+
101
+ @classmethod
102
+ def is_community(cls) -> bool:
103
+ """Is the provider a community-driven project.
104
+
105
+ Returns:
106
+ bool: True if the provider is a community-driven project, False otherwise.
107
+ """
108
+ return cls._is_community
109
+
110
+ @classmethod
111
+ def settings(cls) -> Type[DTMProviderSettings] | None:
112
+ """Settings model of the provider.
113
+
114
+ Returns:
115
+ Type[DTMProviderSettings]: Settings model of the provider.
116
+ """
117
+ return cls._settings
118
+
119
+ @classmethod
120
+ def instructions(cls) -> str | None:
121
+ """Instructions for using the provider.
122
+
123
+ Returns:
124
+ str: Instructions for using the provider.
125
+ """
126
+ return cls._instructions
127
+
128
+ @property
129
+ def user_settings(self) -> DTMProviderSettings | None:
130
+ """User settings of the provider.
131
+
132
+ Returns:
133
+ DTMProviderSettings: User settings of the provider.
134
+ """
135
+ return self._user_settings
136
+
137
+ @classmethod
138
+ def description(cls) -> str:
139
+ """Description of the provider.
140
+
141
+ Returns:
142
+ str: Provider description.
143
+ """
144
+ return f"{cls._icon} {cls._region} [{cls._resolution} m/px] {cls._name}"
145
+
146
+ @classmethod
147
+ def get_provider_by_code(cls, code: str) -> Type[DTMProvider] | None:
148
+ """Get a provider by its code.
149
+
150
+ Arguments:
151
+ code (str): Provider code.
152
+
153
+ Returns:
154
+ DTMProvider: Provider class or None if not found.
155
+ """
156
+ for provider in cls.__subclasses__():
157
+ if provider._code == code: # pylint: disable=W0212
158
+ return provider
159
+ return None
160
+
161
+ @classmethod
162
+ def get_provider_descriptions(cls) -> dict[str, str]:
163
+ """Get descriptions of all providers, where keys are provider codes and
164
+ values are provider descriptions.
165
+
166
+ Returns:
167
+ dict: Provider descriptions.
168
+ """
169
+ providers = {}
170
+ for provider in cls.__subclasses__():
171
+ providers[provider._code] = provider.description() # pylint: disable=W0212
172
+ return providers # type: ignore
173
+
174
+ def download_tile(self, output_path: str, **kwargs) -> bool:
175
+ """Download a tile from the provider.
176
+
177
+ Arguments:
178
+ output_path (str): Path to save the downloaded tile.
179
+
180
+ Returns:
181
+ bool: True if the tile was downloaded successfully, False otherwise.
182
+ """
183
+ url = self.formatted_url(**kwargs)
184
+ response = requests.get(url, stream=True, timeout=10)
185
+ if response.status_code == 200:
186
+ with open(output_path, "wb") as file:
187
+ for chunk in response.iter_content(chunk_size=1024):
188
+ file.write(chunk)
189
+ return True
190
+ return False
191
+
192
+ def get_or_download_tile(self, output_path: str, **kwargs) -> str | None:
193
+ """Get or download a tile from the provider.
194
+
195
+ Arguments:
196
+ output_path (str): Path to save the downloaded tile.
197
+
198
+ Returns:
199
+ str: Path to the downloaded tile or None if the tile not exists and was
200
+ not downloaded.
201
+ """
202
+ if not os.path.exists(output_path):
203
+ if not self.download_tile(output_path, **kwargs):
204
+ return None
205
+ return output_path
206
+
207
+ def get_tile_parameters(self, *args, **kwargs) -> dict:
208
+ """Get parameters for the tile, that will be used to format the URL.
209
+ Must be implemented in subclasses.
210
+
211
+ Returns:
212
+ dict: Tile parameters to format the URL.
213
+ """
214
+ raise NotImplementedError
215
+
216
+ def get_numpy(self) -> np.ndarray:
217
+ """Get numpy array of the tile.
218
+ Resulting array must be 16 bit (signed or unsigned) integer and it should be already
219
+ windowed to the bounding box of ROI. It also must have only one channel.
220
+
221
+ Returns:
222
+ np.ndarray: Numpy array of the tile.
223
+ """
224
+ raise NotImplementedError
225
+
226
+ def get_bbox(self) -> tuple[float, float, float, float]:
227
+ """Get bounding box of the tile based on the center point and size.
228
+
229
+ Returns:
230
+ tuple: Bounding box of the tile (north, south, east, west).
231
+ """
232
+ west, south, east, north = ox.utils_geo.bbox_from_point( # type: ignore
233
+ self.coordinates, dist=self.size // 2, project_utm=False
234
+ )
235
+ bbox = north, south, east, west
236
+ return bbox
237
+
238
+ def extract_roi(self, tile_path: str) -> np.ndarray:
239
+ """Extract region of interest (ROI) from the GeoTIFF file.
240
+
241
+ Arguments:
242
+ tile_path (str): Path to the GeoTIFF file.
243
+
244
+ Raises:
245
+ ValueError: If the tile does not contain any data.
246
+
247
+ Returns:
248
+ np.ndarray: Numpy array of the ROI.
249
+ """
250
+ north, south, east, west = self.get_bbox()
251
+ with rasterio.open(tile_path) as src:
252
+ self.logger.debug("Opened tile, shape: %s, dtype: %s.", src.shape, src.dtypes[0])
253
+ window = rasterio.windows.from_bounds(west, south, east, north, src.transform)
254
+ self.logger.debug(
255
+ "Window parameters. Column offset: %s, row offset: %s, width: %s, height: %s.",
256
+ window.col_off,
257
+ window.row_off,
258
+ window.width,
259
+ window.height,
260
+ )
261
+ data = src.read(1, window=window)
262
+ if not data.size > 0:
263
+ raise ValueError("No data in the tile.")
264
+
265
+ return data
266
+
267
+
268
+ class SRTM30Provider(DTMProvider):
269
+ """Provider of Shuttle Radar Topography Mission (SRTM) 30m data."""
270
+
271
+ _code = "srtm30"
272
+ _name = "SRTM 30 m"
273
+ _region = "Global"
274
+ _icon = "🌎"
275
+ _resolution = 30.0
276
+
277
+ _url = "https://elevation-tiles-prod.s3.amazonaws.com/skadi/{latitude_band}/{tile_name}.hgt.gz"
278
+
279
+ _author = "[iwatkot](https://github.com/iwatkot)"
280
+
281
+ def __init__(self, *args, **kwargs):
282
+ super().__init__(*args, **kwargs)
283
+ self.hgt_directory = os.path.join(self._tile_directory, "hgt")
284
+ self.gz_directory = os.path.join(self._tile_directory, "gz")
285
+ os.makedirs(self.hgt_directory, exist_ok=True)
286
+ os.makedirs(self.gz_directory, exist_ok=True)
287
+
288
+ def get_tile_parameters(self, *args, **kwargs) -> dict[str, str]:
289
+ """Returns latitude band and tile name for SRTM tile from coordinates.
290
+
291
+ Arguments:
292
+ lat (float): Latitude.
293
+ lon (float): Longitude.
294
+
295
+ Returns:
296
+ dict: Tile parameters.
297
+ """
298
+ lat, lon = args
299
+
300
+ tile_latitude = math.floor(lat)
301
+ tile_longitude = math.floor(lon)
302
+
303
+ latitude_band = f"N{abs(tile_latitude):02d}" if lat >= 0 else f"S{abs(tile_latitude):02d}"
304
+ if lon < 0:
305
+ tile_name = f"{latitude_band}W{abs(tile_longitude):03d}"
306
+ else:
307
+ tile_name = f"{latitude_band}E{abs(tile_longitude):03d}"
308
+
309
+ self.logger.debug(
310
+ "Detected tile name: %s for coordinates: lat %s, lon %s.", tile_name, lat, lon
311
+ )
312
+ return {"latitude_band": latitude_band, "tile_name": tile_name}
313
+
314
+ def get_numpy(self) -> np.ndarray:
315
+ """Get numpy array of the tile.
316
+
317
+ Returns:
318
+ np.ndarray: Numpy array of the tile.
319
+ """
320
+ tile_parameters = self.get_tile_parameters(*self.coordinates)
321
+ tile_name = tile_parameters["tile_name"]
322
+ decompressed_tile_path = os.path.join(self.hgt_directory, f"{tile_name}.hgt")
323
+
324
+ if not os.path.isfile(decompressed_tile_path):
325
+ compressed_tile_path = os.path.join(self.gz_directory, f"{tile_name}.hgt.gz")
326
+ if not self.get_or_download_tile(compressed_tile_path, **tile_parameters):
327
+ raise FileNotFoundError(f"Tile {tile_name} not found.")
328
+
329
+ with gzip.open(compressed_tile_path, "rb") as f_in:
330
+ with open(decompressed_tile_path, "wb") as f_out:
331
+ shutil.copyfileobj(f_in, f_out)
332
+
333
+ return self.extract_roi(decompressed_tile_path)
maps4fs/generator/game.py CHANGED
@@ -10,6 +10,7 @@ from maps4fs.generator.background import Background
10
10
  from maps4fs.generator.config import Config
11
11
  from maps4fs.generator.grle import GRLE
12
12
  from maps4fs.generator.i3d import I3d
13
+ from maps4fs.generator.satellite import Satellite
13
14
  from maps4fs.generator.texture import Texture
14
15
 
15
16
  working_directory = os.getcwd()
@@ -39,7 +40,7 @@ class Game:
39
40
  _tree_schema: str | None = None
40
41
 
41
42
  # Order matters! Some components depend on others.
42
- components = [Texture, GRLE, Background, I3d, Config]
43
+ components = [Texture, GRLE, Background, I3d, Config, Satellite]
43
44
 
44
45
  def __init__(self, map_template_path: str | None = None):
45
46
  if map_template_path:
maps4fs/generator/grle.py CHANGED
@@ -116,6 +116,11 @@ class GRLE(Component):
116
116
 
117
117
  self.logger.info("Found %s fields in textures info layer.", len(fields))
118
118
 
119
+ farmyards: list[list[tuple[int, int]]] | None = textures_info_layer.get("farmyards")
120
+ if farmyards and self.map.grle_settings.add_farmyards:
121
+ fields.extend(farmyards)
122
+ self.logger.info("Found %s farmyards in textures info layer.", len(farmyards))
123
+
119
124
  info_layer_farmlands_path = os.path.join(
120
125
  self.game.weights_dir_path(self.map_directory), "infoLayer_farmlands.png"
121
126
  )
maps4fs/generator/i3d.py CHANGED
@@ -81,20 +81,6 @@ class I3d(Component):
81
81
 
82
82
  root = tree.getroot()
83
83
  for map_elem in root.iter("Scene"):
84
- for terrain_elem in map_elem.iter("TerrainTransformGroup"):
85
- if self.map.dem_settings.auto_process:
86
- terrain_elem.set("heightScale", str(DEFAULT_HEIGHT_SCALE))
87
- self.logger.debug(
88
- "heightScale attribute set to %s in TerrainTransformGroup element.",
89
- DEFAULT_HEIGHT_SCALE,
90
- )
91
- else:
92
- self.logger.debug(
93
- "Auto process is disabled, skipping the heightScale attribute update."
94
- )
95
-
96
- self.logger.debug("TerrainTransformGroup element updated in I3D file.")
97
-
98
84
  sun_elem = map_elem.find(".//Light[@name='sun']")
99
85
 
100
86
  if sun_elem is not None:
@@ -111,6 +97,10 @@ class I3d(Component):
111
97
  )
112
98
 
113
99
  if self.map_size > 4096:
100
+ terrain_elem = root.find(".//TerrainTransformGroup")
101
+ if terrain_elem is None:
102
+ self.logger.warning("TerrainTransformGroup element not found in I3D file.")
103
+ return
114
104
  displacement_layer = terrain_elem.find(".//DisplacementLayer") # pylint: disable=W0631
115
105
 
116
106
  if displacement_layer is not None:
@@ -184,6 +174,10 @@ class I3d(Component):
184
174
 
185
175
  if shapes_node is not None and scene_node is not None:
186
176
  node_id = SPLINES_NODE_ID_STARTING_VALUE
177
+ user_attributes_node = root.find(".//UserAttributes")
178
+ if user_attributes_node is None:
179
+ self.logger.warning("UserAttributes node not found in I3D file.")
180
+ return
187
181
 
188
182
  for road_id, road in enumerate(roads_polylines, start=1):
189
183
  # Add to scene node
@@ -254,6 +248,25 @@ class I3d(Component):
254
248
 
255
249
  shapes_node.append(nurbs_curve_node)
256
250
 
251
+ # Add UserAttributes to the shape node.
252
+ # <UserAttribute nodeId="5000">
253
+ # <Attribute name="maxSpeedScale" type="integer" value="1"/>
254
+ # <Attribute name="speedLimit" type="integer" value="100"/>
255
+ # </UserAttribute>
256
+
257
+ user_attribute_node = ET.Element("UserAttribute")
258
+ user_attribute_node.set("nodeId", str(node_id))
259
+
260
+ attributes = [
261
+ ("maxSpeedScale", "integer", "1"),
262
+ ("speedLimit", "integer", "100"),
263
+ ]
264
+
265
+ for name, attr_type, value in attributes:
266
+ user_attribute_node.append(I3d.create_attribute_node(name, attr_type, value))
267
+
268
+ user_attributes_node.append(user_attribute_node) # type: ignore
269
+
257
270
  node_id += 1
258
271
 
259
272
  tree.write(splines_i3d_path) # type: ignore
maps4fs/generator/map.py CHANGED
@@ -7,129 +7,21 @@ import os
7
7
  import shutil
8
8
  from typing import Any, Generator
9
9
 
10
- from pydantic import BaseModel
11
-
12
10
  from maps4fs.generator.component import Component
11
+ from maps4fs.generator.dtm import DTMProvider, DTMProviderSettings
13
12
  from maps4fs.generator.game import Game
13
+ from maps4fs.generator.settings import (
14
+ BackgroundSettings,
15
+ DEMSettings,
16
+ GRLESettings,
17
+ I3DSettings,
18
+ SatelliteSettings,
19
+ SplineSettings,
20
+ TextureSettings,
21
+ )
14
22
  from maps4fs.logger import Logger
15
23
 
16
24
 
17
- class SettingsModel(BaseModel):
18
- """Base class for settings models. It provides methods to convert settings to and from JSON."""
19
-
20
- @classmethod
21
- def all_settings_to_json(cls) -> dict[str, dict[str, Any]]:
22
- """Get all settings of the current class and its subclasses as a dictionary.
23
-
24
- Returns:
25
- dict[str, dict[str, Any]]: Dictionary with settings of the current class and its
26
- subclasses.
27
- """
28
- all_settings = {}
29
- for subclass in cls.__subclasses__():
30
- all_settings[subclass.__name__] = subclass().model_dump()
31
-
32
- return all_settings
33
-
34
- @classmethod
35
- def all_settings_from_json(cls, data: dict) -> dict[str, SettingsModel]:
36
- """Create settings instances from JSON data.
37
-
38
- Arguments:
39
- data (dict): JSON data.
40
-
41
- Returns:
42
- dict[str, Type[SettingsModel]]: Dictionary with settings instances.
43
- """
44
- settings = {}
45
- for subclass in cls.__subclasses__():
46
- settings[subclass.__name__] = subclass(**data[subclass.__name__])
47
-
48
- return settings
49
-
50
-
51
- class DEMSettings(SettingsModel):
52
- """Represents the advanced settings for DEM component.
53
-
54
- Attributes:
55
- auto_process (bool): use the auto preset to change the multiplier.
56
- multiplier (int): multiplier for the heightmap, every pixel will be multiplied by this
57
- value.
58
- blur_radius (int): radius of the blur filter.
59
- plateau (int): plateau height, will be added to each pixel.
60
- water_depth (int): water depth, will be subtracted from each pixel where the water
61
- is present.
62
- """
63
-
64
- auto_process: bool = True
65
- multiplier: int = 1
66
- blur_radius: int = 35
67
- plateau: int = 0
68
- water_depth: int = 0
69
-
70
-
71
- class BackgroundSettings(SettingsModel):
72
- """Represents the advanced settings for background component.
73
-
74
- Attributes:
75
- generate_background (bool): generate obj files for the background terrain.
76
- generate_water (bool): generate obj files for the water.
77
- resize_factor (int): resize factor for the background terrain and water.
78
- It will be used as 1 / resize_factor of the original size.
79
- """
80
-
81
- generate_background: bool = True
82
- generate_water: bool = True
83
- resize_factor: int = 8
84
-
85
-
86
- class GRLESettings(SettingsModel):
87
- """Represents the advanced settings for GRLE component.
88
-
89
- Attributes:
90
- farmland_margin (int): margin around the farmland.
91
- random_plants (bool): generate random plants on the map or use the default one.
92
- """
93
-
94
- farmland_margin: int = 0
95
- random_plants: bool = True
96
-
97
-
98
- class I3DSettings(SettingsModel):
99
- """Represents the advanced settings for I3D component.
100
-
101
- Attributes:
102
- forest_density (int): density of the forest (distance between trees).
103
- """
104
-
105
- forest_density: int = 10
106
-
107
-
108
- class TextureSettings(SettingsModel):
109
- """Represents the advanced settings for texture component.
110
-
111
- Attributes:
112
- dissolve (bool): dissolve the texture into several images.
113
- fields_padding (int): padding around the fields.
114
- skip_drains (bool): skip drains generation.
115
- """
116
-
117
- dissolve: bool = True
118
- fields_padding: int = 0
119
- skip_drains: bool = False
120
-
121
-
122
- class SplineSettings(SettingsModel):
123
- """Represents the advanced settings for spline component.
124
-
125
- Attributes:
126
- spline_density (int): the number of extra points that will be added between each two
127
- existing points.
128
- """
129
-
130
- spline_density: int = 4
131
-
132
-
133
25
  # pylint: disable=R0913, R0902, R0914
134
26
  class Map:
135
27
  """Class used to generate map using all components.
@@ -145,6 +37,8 @@ class Map:
145
37
  def __init__( # pylint: disable=R0917, R0915
146
38
  self,
147
39
  game: Game,
40
+ dtm_provider: DTMProvider,
41
+ dtm_provider_settings: DTMProviderSettings,
148
42
  coordinates: tuple[float, float],
149
43
  size: int,
150
44
  rotation: int,
@@ -157,6 +51,7 @@ class Map:
157
51
  i3d_settings: I3DSettings = I3DSettings(),
158
52
  texture_settings: TextureSettings = TextureSettings(),
159
53
  spline_settings: SplineSettings = SplineSettings(),
54
+ satellite_settings: SatelliteSettings = SatelliteSettings(),
160
55
  **kwargs,
161
56
  ):
162
57
  if not logger:
@@ -173,6 +68,8 @@ class Map:
173
68
  self.rotated_size = int(size * rotation_multiplier)
174
69
 
175
70
  self.game = game
71
+ self.dtm_provider = dtm_provider
72
+ self.dtm_provider_settings = dtm_provider_settings
176
73
  self.components: list[Component] = []
177
74
  self.coordinates = coordinates
178
75
  self.map_directory = map_directory
@@ -201,10 +98,31 @@ class Map:
201
98
  self.logger.info("Texture settings: %s", texture_settings)
202
99
  self.spline_settings = spline_settings
203
100
  self.logger.info("Spline settings: %s", spline_settings)
101
+ self.satellite_settings = satellite_settings
204
102
 
205
103
  os.makedirs(self.map_directory, exist_ok=True)
206
104
  self.logger.debug("Map directory created: %s", self.map_directory)
207
105
 
106
+ settings = [
107
+ dem_settings,
108
+ background_settings,
109
+ grle_settings,
110
+ i3d_settings,
111
+ texture_settings,
112
+ spline_settings,
113
+ satellite_settings,
114
+ ]
115
+
116
+ settings_json = {}
117
+
118
+ for setting in settings:
119
+ settings_json[setting.__class__.__name__] = setting.model_dump()
120
+
121
+ save_path = os.path.join(self.map_directory, "generation_settings.json")
122
+
123
+ with open(save_path, "w", encoding="utf-8") as file:
124
+ json.dump(settings_json, file, indent=4)
125
+
208
126
  self.texture_custom_schema = kwargs.get("texture_custom_schema", None)
209
127
  if self.texture_custom_schema:
210
128
  save_path = os.path.join(self.map_directory, "texture_custom_schema.json")