maps4fs 1.5.7__py3-none-any.whl → 1.6.91__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 CHANGED
@@ -1,5 +1,7 @@
1
1
  # pylint: disable=missing-module-docstring
2
- from maps4fs.generator.dtm import DTMProvider
2
+ from maps4fs.generator.dtm.dtm import DTMProvider
3
+ from maps4fs.generator.dtm.srtm import SRTM30Provider
4
+ from maps4fs.generator.dtm.usgs import USGSProvider
3
5
  from maps4fs.generator.game import Game
4
6
  from maps4fs.generator.map import Map
5
7
  from maps4fs.generator.settings import (
@@ -58,6 +58,10 @@ class Background(Component):
58
58
  os.makedirs(self.water_directory, exist_ok=True)
59
59
 
60
60
  self.output_path = os.path.join(self.background_directory, f"{FULL_NAME}.png")
61
+ if self.map.custom_background_path:
62
+ self.check_custom_background(self.map.custom_background_path)
63
+ shutil.copyfile(self.map.custom_background_path, self.output_path)
64
+
61
65
  self.not_substracted_path = os.path.join(self.background_directory, "not_substracted.png")
62
66
  self.not_resized_path = os.path.join(self.background_directory, "not_resized.png")
63
67
 
@@ -75,6 +79,28 @@ class Background(Component):
75
79
  self.dem.set_output_resolution((self.rotated_size, self.rotated_size))
76
80
  self.dem.set_dem_path(self.output_path)
77
81
 
82
+ def check_custom_background(self, image_path: str) -> None:
83
+ """Checks if the custom background image meets the requirements.
84
+
85
+ Arguments:
86
+ image_path (str): The path to the custom background image.
87
+
88
+ Raises:
89
+ ValueError: If the custom background image does not meet the requirements.
90
+ """
91
+ image = cv2.imread(image_path, cv2.IMREAD_UNCHANGED) # pylint: disable=no-member
92
+ if image.shape[0] != image.shape[1]:
93
+ raise ValueError("The custom background image must be a square.")
94
+
95
+ if image.shape[0] != self.map_size + DEFAULT_DISTANCE * 2:
96
+ raise ValueError("The custom background image must have the size of the map + 4096.")
97
+
98
+ if len(image.shape) != 2:
99
+ raise ValueError("The custom background image must be a grayscale image.")
100
+
101
+ if image.dtype != np.uint16:
102
+ raise ValueError("The custom background image must be a 16-bit grayscale image.")
103
+
78
104
  def is_preview(self, name: str) -> bool:
79
105
  """Checks if the DEM is a preview.
80
106
 
@@ -91,7 +117,9 @@ class Background(Component):
91
117
  as a result the DEM files will be saved, then based on them the obj files will be
92
118
  generated."""
93
119
  self.create_background_textures()
94
- self.dem.process()
120
+
121
+ if not self.map.custom_background_path:
122
+ self.dem.process()
95
123
 
96
124
  shutil.copyfile(self.dem.dem_path, self.not_substracted_path)
97
125
  self.cutout(self.dem.dem_path, save_path=self.not_resized_path)
@@ -148,6 +176,9 @@ class Background(Component):
148
176
  "east": east,
149
177
  "west": west,
150
178
  }
179
+
180
+ dem_info_sequence = self.dem.info_sequence()
181
+ data["DEM"] = dem_info_sequence
151
182
  return data # type: ignore
152
183
 
153
184
  def qgis_sequence(self) -> None:
@@ -176,7 +207,13 @@ class Background(Component):
176
207
  self.logger.debug("Generating obj file in path: %s", save_path)
177
208
 
178
209
  dem_data = cv2.imread(self.dem.dem_path, cv2.IMREAD_UNCHANGED) # pylint: disable=no-member
179
- self.plane_from_np(dem_data, save_path) # type: ignore
210
+ self.plane_from_np(
211
+ dem_data,
212
+ save_path,
213
+ create_preview=True,
214
+ remove_center=self.map.background_settings.remove_center,
215
+ include_zeros=False,
216
+ ) # type: ignore
180
217
 
181
218
  # pylint: disable=too-many-locals
182
219
  def cutout(self, dem_path: str, save_path: str | None = None) -> str:
@@ -219,16 +256,37 @@ class Background(Component):
219
256
  )
220
257
 
221
258
  cv2.imwrite(main_dem_path, resized_dem_data) # pylint: disable=no-member
222
- self.logger.info("DEM cutout saved: %s", main_dem_path)
259
+ self.logger.debug("DEM cutout saved: %s", main_dem_path)
223
260
 
224
261
  return main_dem_path
225
262
 
226
- # pylint: disable=too-many-locals
263
+ def remove_center(self, dem_data: np.ndarray, resize_factor: float) -> np.ndarray:
264
+ """Removes the center part of the DEM data.
265
+
266
+ Arguments:
267
+ dem_data (np.ndarray) -- The DEM data as a numpy array.
268
+ resize_factor (float) -- The resize factor of the DEM data.
269
+
270
+ Returns:
271
+ np.ndarray -- The DEM data with the center part removed.
272
+ """
273
+ center = (dem_data.shape[0] // 2, dem_data.shape[1] // 2)
274
+ half_size = int(self.map_size // 2 * resize_factor)
275
+ x1 = center[0] - half_size
276
+ x2 = center[0] + half_size
277
+ y1 = center[1] - half_size
278
+ y2 = center[1] + half_size
279
+ dem_data[x1:x2, y1:y2] = 0
280
+ return dem_data
281
+
282
+ # pylint: disable=R0913, R0917, R0915
227
283
  def plane_from_np(
228
284
  self,
229
285
  dem_data: np.ndarray,
230
286
  save_path: str,
231
287
  include_zeros: bool = True,
288
+ create_preview: bool = False,
289
+ remove_center: bool = False,
232
290
  ) -> None:
233
291
  """Generates a 3D obj file based on DEM data.
234
292
 
@@ -236,11 +294,18 @@ class Background(Component):
236
294
  dem_data (np.ndarray) -- The DEM data as a numpy array.
237
295
  save_path (str) -- The path where the obj file will be saved.
238
296
  include_zeros (bool, optional) -- If True, the mesh will include the zero height values.
297
+ create_preview (bool, optional) -- If True, a simplified mesh will be saved as an STL.
298
+ remove_center (bool, optional) -- If True, the center of the mesh will be removed.
299
+ This setting is used for a Background Terrain, where the center part where the
300
+ playable area is will be cut out.
239
301
  """
240
302
  resize_factor = 1 / self.map.background_settings.resize_factor
241
303
  dem_data = cv2.resize( # pylint: disable=no-member
242
304
  dem_data, (0, 0), fx=resize_factor, fy=resize_factor
243
305
  )
306
+ if remove_center:
307
+ dem_data = self.remove_center(dem_data, resize_factor)
308
+ self.logger.debug("Center removed from DEM data.")
244
309
  self.logger.debug(
245
310
  "DEM data resized to shape: %s with factor: %s", dem_data.shape, resize_factor
246
311
  )
@@ -275,7 +340,10 @@ class Background(Component):
275
340
  bottom_left = top_left + cols
276
341
  bottom_right = bottom_left + 1
277
342
 
278
- if ground in [z[i, j], z[i, j + 1], z[i + 1, j], z[i + 1, j + 1]]:
343
+ if (
344
+ ground in [z[i, j], z[i, j + 1], z[i + 1, j], z[i + 1, j + 1]]
345
+ and not include_zeros
346
+ ):
279
347
  skipped += 1
280
348
  continue
281
349
 
@@ -294,16 +362,32 @@ class Background(Component):
294
362
  mesh.apply_transform(rotation_matrix_z)
295
363
 
296
364
  # if not include_zeros:
297
- z_scaling_factor = 1 / self.map.dem_settings.multiplier
365
+ z_scaling_factor = self.get_z_scaling_factor()
298
366
  self.logger.debug("Z scaling factor: %s", z_scaling_factor)
299
367
  mesh.apply_scale([1 / resize_factor, 1 / resize_factor, z_scaling_factor])
300
368
 
369
+ old_faces = len(mesh.faces)
370
+ self.logger.debug("Mesh generated with %s faces.", old_faces)
371
+
372
+ if self.map.background_settings.apply_decimation:
373
+ percent = self.map.background_settings.decimation_percent / 100
374
+ mesh = mesh.simplify_quadric_decimation(
375
+ percent=percent, aggression=self.map.background_settings.decimation_agression
376
+ )
377
+
378
+ new_faces = len(mesh.faces)
379
+ decimation_percent = (old_faces - new_faces) / old_faces * 100
380
+
381
+ self.logger.debug(
382
+ "Mesh simplified to %s faces. Decimation percent: %s", new_faces, decimation_percent
383
+ )
384
+
301
385
  mesh.export(save_path)
302
386
  self.logger.debug("Obj file saved: %s", save_path)
303
387
 
304
- if include_zeros:
388
+ if create_preview:
305
389
  # Simplify the preview mesh to reduce the size of the file.
306
- mesh = mesh.simplify_quadric_decimation(face_count=len(mesh.faces) // 2**7)
390
+ # mesh = mesh.simplify_quadric_decimation(face_count=len(mesh.faces) // 2**7)
307
391
 
308
392
  # Apply scale to make the preview mesh smaller in the UI.
309
393
  mesh.apply_scale([0.5, 0.5, 0.5])
@@ -58,7 +58,7 @@ class Component:
58
58
  self.logger = logger
59
59
  self.kwargs = kwargs
60
60
 
61
- self.logger.info(
61
+ self.logger.debug(
62
62
  "Component %s initialized. Map size: %s, map rotated size: %s", # type: ignore
63
63
  self.__class__.__name__,
64
64
  self.map_size,
@@ -535,3 +535,26 @@ class Component:
535
535
  interpolated_polyline.append(polyline[-1])
536
536
 
537
537
  return interpolated_polyline
538
+
539
+ def get_z_scaling_factor(self) -> float:
540
+ """Calculates the scaling factor for the Z axis based on the map settings.
541
+
542
+ Returns:
543
+ float -- The scaling factor for the Z axis.
544
+ """
545
+
546
+ scaling_factor = 1 / self.map.dem_settings.multiplier
547
+ self.logger.debug("Z scaling factor including DEM multiplier: %s", scaling_factor)
548
+
549
+ if self.map.shared_settings.height_scale_multiplier:
550
+ scaling_factor *= self.map.shared_settings.height_scale_multiplier
551
+ self.logger.debug(
552
+ "Z scaling factor including height scale multiplier: %s", scaling_factor
553
+ )
554
+ if self.map.shared_settings.mesh_z_scaling_factor:
555
+ scaling_factor *= 1 / self.map.shared_settings.mesh_z_scaling_factor
556
+ self.logger.debug(
557
+ "Z scaling factor including mesh z scaling factor: %s", scaling_factor
558
+ )
559
+
560
+ return scaling_factor
maps4fs/generator/dem.py CHANGED
@@ -1,6 +1,7 @@
1
1
  """This module contains DEM class for processing Digital Elevation Model data."""
2
2
 
3
3
  import os
4
+ from typing import Any
4
5
 
5
6
  import cv2
6
7
  import numpy as np
@@ -9,7 +10,7 @@ import numpy as np
9
10
  from pympler import asizeof # type: ignore
10
11
 
11
12
  from maps4fs.generator.component import Component
12
- from maps4fs.generator.dtm import DTMProvider
13
+ from maps4fs.generator.dtm.dtm import DTMProvider
13
14
 
14
15
 
15
16
  # pylint: disable=R0903, R0902
@@ -63,6 +64,7 @@ class DEM(Component):
63
64
  size=self.map_rotated_size,
64
65
  directory=self.temp_dir,
65
66
  logger=self.logger,
67
+ map=self.map,
66
68
  )
67
69
 
68
70
  @property
@@ -281,54 +283,15 @@ class DEM(Component):
281
283
  """
282
284
  return []
283
285
 
284
- def _get_scaling_factor(self, maximum_deviation: int) -> float:
285
- """Calculate scaling factor for DEM data normalization.
286
- NOTE: Needs reconsideration for the implementation.
286
+ def info_sequence(self) -> dict[Any, Any] | None: # type: ignore
287
+ """Returns the information sequence for the component. Must be implemented in the child
288
+ class. If the component does not have an information sequence, an empty dictionary must be
289
+ returned.
287
290
 
288
- Arguments:
289
- maximum_deviation (int): Maximum deviation in DEM data.
290
-
291
- Returns:
292
- float: Scaling factor for DEM data normalization.
293
- """
294
- ESTIMATED_MAXIMUM_DEVIATION = 1000 # pylint: disable=C0103
295
- scaling_factor = maximum_deviation / ESTIMATED_MAXIMUM_DEVIATION
296
- return scaling_factor if scaling_factor < 1 else 1
297
-
298
- def _normalize_dem(self, data: np.ndarray) -> np.ndarray:
299
- """Normalize DEM data to 16-bit unsigned integer using max height from settings.
300
- Arguments:
301
- data (np.ndarray): DEM data from SRTM file after cropping.
302
291
  Returns:
303
- np.ndarray: Normalized DEM data.
292
+ dict[Any, Any]: The information sequence for the component.
304
293
  """
305
- self.logger.debug("Starting DEM data normalization.")
306
- # Calculate the difference between the maximum and minimum values in the DEM data.
307
-
308
- max_height = data.max()
309
- min_height = data.min()
310
- max_dev = max_height - min_height
311
- self.logger.debug(
312
- "Maximum deviation: %s with maximum at %s and minimum at %s.",
313
- max_dev,
314
- max_height,
315
- min_height,
316
- )
317
-
318
- scaling_factor = self._get_scaling_factor(max_dev)
319
- adjusted_max_height = int(65535 * scaling_factor)
320
- self.logger.debug(
321
- "Maximum deviation: %s. Scaling factor: %s. Adjusted max height: %s.",
322
- max_dev,
323
- scaling_factor,
324
- adjusted_max_height,
325
- )
326
- normalized_data = (
327
- (data - data.min()) / (data.max() - data.min()) * adjusted_max_height
328
- ).astype("uint16")
329
- self.logger.debug(
330
- "DEM data was normalized to %s - %s.",
331
- normalized_data.min(),
332
- normalized_data.max(),
333
- )
334
- return normalized_data
294
+ provider_info_sequence = self.dtm_provider.info_sequence()
295
+ if provider_info_sequence is None:
296
+ return {}
297
+ return provider_info_sequence
File without changes
@@ -4,11 +4,9 @@ and specific settings for downloading and processing the data."""
4
4
 
5
5
  from __future__ import annotations
6
6
 
7
- import gzip
8
- import math
7
+ from abc import ABC, abstractmethod
9
8
  import os
10
- import shutil
11
- from typing import Type
9
+ from typing import TYPE_CHECKING, Type
12
10
 
13
11
  import numpy as np
14
12
  import osmnx as ox # type: ignore
@@ -18,24 +16,30 @@ from pydantic import BaseModel
18
16
 
19
17
  from maps4fs.logger import Logger
20
18
 
19
+ if TYPE_CHECKING:
20
+ from maps4fs.generator.map import Map
21
+
21
22
 
22
23
  class DTMProviderSettings(BaseModel):
23
24
  """Base class for DTM provider settings models."""
24
25
 
25
26
 
26
- class DTMProvider:
27
+ # pylint: disable=too-many-public-methods
28
+ class DTMProvider(ABC):
27
29
  """Base class for DTM providers."""
28
30
 
29
31
  _code: str | None = None
30
32
  _name: str | None = None
31
33
  _region: str | None = None
32
34
  _icon: str | None = None
33
- _resolution: float | None = None
35
+ _resolution: float | str | None = None
34
36
 
35
37
  _url: str | None = None
36
38
 
37
39
  _author: str | None = None
40
+ _contributors: str | None = None
38
41
  _is_community: bool = False
42
+ _is_base: bool = False
39
43
  _settings: Type[DTMProviderSettings] | None = None
40
44
 
41
45
  _instructions: str | None = None
@@ -48,6 +52,7 @@ class DTMProvider:
48
52
  size: int,
49
53
  directory: str,
50
54
  logger: Logger,
55
+ map: Map | None = None, # pylint: disable=W0622
51
56
  ):
52
57
  self._coordinates = coordinates
53
58
  self._user_settings = user_settings
@@ -59,6 +64,27 @@ class DTMProvider:
59
64
  os.makedirs(self._tile_directory, exist_ok=True)
60
65
 
61
66
  self.logger = logger
67
+ self.map = map
68
+
69
+ self._data_info: dict[str, int | str | float] | None = None
70
+
71
+ @property
72
+ def data_info(self) -> dict[str, int | str | float] | None:
73
+ """Information about the DTM data.
74
+
75
+ Returns:
76
+ dict: Information about the DTM data.
77
+ """
78
+ return self._data_info
79
+
80
+ @data_info.setter
81
+ def data_info(self, value: dict[str, int | str | float] | None) -> None:
82
+ """Set information about the DTM data.
83
+
84
+ Arguments:
85
+ value (dict): Information about the DTM data.
86
+ """
87
+ self._data_info = value
62
88
 
63
89
  @property
64
90
  def coordinates(self) -> tuple[float, float]:
@@ -98,6 +124,24 @@ class DTMProvider:
98
124
  """
99
125
  return cls._author
100
126
 
127
+ @classmethod
128
+ def contributors(cls) -> str | None:
129
+ """Contributors of the provider.
130
+
131
+ Returns:
132
+ str: Contributors of the provider.
133
+ """
134
+ return cls._contributors
135
+
136
+ @classmethod
137
+ def is_base(cls) -> bool:
138
+ """Is the provider a base provider.
139
+
140
+ Returns:
141
+ bool: True if the provider is a base provider, False otherwise.
142
+ """
143
+ return cls._is_base
144
+
101
145
  @classmethod
102
146
  def is_community(cls) -> bool:
103
147
  """Is the provider a community-driven project.
@@ -168,7 +212,8 @@ class DTMProvider:
168
212
  """
169
213
  providers = {}
170
214
  for provider in cls.__subclasses__():
171
- providers[provider._code] = provider.description() # pylint: disable=W0212
215
+ if not provider.is_base():
216
+ providers[provider._code] = provider.description() # pylint: disable=W0212
172
217
  return providers # type: ignore
173
218
 
174
219
  def download_tile(self, output_path: str, **kwargs) -> bool:
@@ -213,6 +258,7 @@ class DTMProvider:
213
258
  """
214
259
  raise NotImplementedError
215
260
 
261
+ @abstractmethod
216
262
  def get_numpy(self) -> np.ndarray:
217
263
  """Get numpy array of the tile.
218
264
  Resulting array must be 16 bit (signed or unsigned) integer and it should be already
@@ -264,70 +310,12 @@ class DTMProvider:
264
310
 
265
311
  return data
266
312
 
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.
313
+ def info_sequence(self) -> dict[str, int | str | float] | None:
314
+ """Returns the information sequence for the component. Must be implemented in the child
315
+ class. If the component does not have an information sequence, an empty dictionary must be
316
+ returned.
294
317
 
295
318
  Returns:
296
- dict: Tile parameters.
319
+ dict[str, int | str | float] | None: Information sequence for the component.
297
320
  """
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)
321
+ return self.data_info