maps4fs 1.8.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.
@@ -0,0 +1,297 @@
1
+ """This module contains DEM class for processing Digital Elevation Model data."""
2
+
3
+ import os
4
+ from typing import Any
5
+
6
+ import cv2
7
+ import numpy as np
8
+
9
+ # import rasterio # type: ignore
10
+ from pympler import asizeof # type: ignore
11
+
12
+ from maps4fs.generator.component import Component
13
+ from maps4fs.generator.dtm.dtm import DTMProvider
14
+
15
+
16
+ # pylint: disable=R0903, R0902
17
+ class DEM(Component):
18
+ """Component for processing Digital Elevation Model data.
19
+
20
+ Arguments:
21
+ game (Game): The game instance for which the map is generated.
22
+ coordinates (tuple[float, float]): The latitude and longitude of the center of the map.
23
+ map_size (int): The size of the map in pixels.
24
+ map_rotated_size (int): The size of the map in pixels after rotation.
25
+ rotation (int): The rotation angle 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._dem_path = self.game.dem_file_path(self.map_directory)
33
+ self.temp_dir = "temp"
34
+ self.hgt_dir = os.path.join(self.temp_dir, "hgt")
35
+ self.gz_dir = os.path.join(self.temp_dir, "gz")
36
+ os.makedirs(self.hgt_dir, exist_ok=True)
37
+ os.makedirs(self.gz_dir, exist_ok=True)
38
+
39
+ self.logger.debug("Map size: %s x %s.", self.map_size, self.map_size)
40
+ self.logger.debug(
41
+ "Map rotated size: %s x %s.", self.map_rotated_size, self.map_rotated_size
42
+ )
43
+
44
+ self.output_resolution = self.get_output_resolution()
45
+ self.logger.debug("Output resolution for DEM data: %s.", self.output_resolution)
46
+
47
+ blur_radius = self.map.dem_settings.blur_radius
48
+ if blur_radius is None or blur_radius <= 0:
49
+ # We'll disable blur if the radius is 0 or negative.
50
+ blur_radius = 0
51
+ elif blur_radius % 2 == 0:
52
+ blur_radius += 1
53
+ self.blur_radius = blur_radius
54
+ self.multiplier = self.map.dem_settings.multiplier
55
+ self.logger.debug(
56
+ "DEM value multiplier is %s, blur radius is %s.",
57
+ self.multiplier,
58
+ self.blur_radius,
59
+ )
60
+
61
+ self.dtm_provider: DTMProvider = self.map.dtm_provider( # type: ignore
62
+ coordinates=self.coordinates,
63
+ user_settings=self.map.dtm_provider_settings,
64
+ size=self.map_rotated_size,
65
+ directory=self.temp_dir,
66
+ logger=self.logger,
67
+ map=self.map,
68
+ )
69
+
70
+ @property
71
+ def dem_path(self) -> str:
72
+ """Returns path to the DEM file.
73
+
74
+ Returns:
75
+ str: Path to the DEM file.
76
+ """
77
+ return self._dem_path
78
+
79
+ # pylint: disable=W0201
80
+ def set_dem_path(self, dem_path: str) -> None:
81
+ """Set path to the DEM file.
82
+
83
+ Arguments:
84
+ dem_path (str): Path to the DEM file.
85
+ """
86
+ self._dem_path = dem_path
87
+
88
+ # pylint: disable=W0201
89
+ def set_output_resolution(self, output_resolution: tuple[int, int]) -> None:
90
+ """Set output resolution for DEM data (width, height).
91
+
92
+ Arguments:
93
+ output_resolution (tuple[int, int]): Output resolution for DEM data.
94
+ """
95
+ self.output_resolution = output_resolution
96
+
97
+ def get_output_resolution(self, use_original: bool = False) -> tuple[int, int]:
98
+ """Get output resolution for DEM data.
99
+
100
+ Arguments:
101
+ use_original (bool, optional): If True, will use original map size. Defaults to False.
102
+
103
+ Returns:
104
+ tuple[int, int]: Output resolution for DEM data.
105
+ """
106
+ map_size = self.map_size if use_original else self.map_rotated_size
107
+
108
+ dem_size = int((map_size / 2) * self.game.dem_multipliyer)
109
+
110
+ self.logger.debug(
111
+ "DEM size multiplier is %s, DEM size: %sx%s, use original: %s.",
112
+ self.game.dem_multipliyer,
113
+ dem_size,
114
+ dem_size,
115
+ use_original,
116
+ )
117
+ return dem_size, dem_size
118
+
119
+ def to_ground(self, data: np.ndarray) -> np.ndarray:
120
+ """Receives the signed 16-bit integer array and converts it to the ground level.
121
+ If the min value is negative, it will become zero value and the rest of the values
122
+ will be shifted accordingly.
123
+ """
124
+ # For examlem, min value was -50, it will become 0 and for all values we'll +50.
125
+
126
+ if data.min() < 0:
127
+ self.logger.debug("Array contains negative values, will be shifted to the ground.")
128
+ data = data + abs(data.min())
129
+
130
+ self.logger.debug(
131
+ "Array was shifted to the ground. Min: %s, max: %s.", data.min(), data.max()
132
+ )
133
+ return data
134
+
135
+ # pylint: disable=no-member
136
+ def process(self) -> None:
137
+ """Reads SRTM file, crops it to map size, normalizes and blurs it,
138
+ saves to map directory."""
139
+
140
+ dem_output_resolution = self.output_resolution
141
+ self.logger.debug("DEM output resolution: %s.", dem_output_resolution)
142
+
143
+ try:
144
+ data = self.dtm_provider.get_numpy()
145
+ except Exception as e: # pylint: disable=W0718
146
+ self.logger.error("Failed to get DEM data from SRTM: %s.", e)
147
+ self._save_empty_dem(dem_output_resolution)
148
+ return
149
+
150
+ if len(data.shape) != 2:
151
+ self.logger.error("DTM provider returned incorrect data: more than 1 channel.")
152
+ self._save_empty_dem(dem_output_resolution)
153
+ return
154
+
155
+ if data.dtype not in ["int16", "uint16"]:
156
+ self.logger.error("DTM provider returned incorrect data type: %s.", data.dtype)
157
+ self._save_empty_dem(dem_output_resolution)
158
+ return
159
+
160
+ self.logger.debug(
161
+ "DEM data was retrieved from DTM provider. Shape: %s, dtype: %s. Min: %s, max: %s.",
162
+ data.shape,
163
+ data.dtype,
164
+ data.min(),
165
+ data.max(),
166
+ )
167
+
168
+ data = self.to_ground(data)
169
+
170
+ resampled_data = cv2.resize(
171
+ data, dem_output_resolution, interpolation=cv2.INTER_LINEAR
172
+ ).astype("uint16")
173
+
174
+ size_of_resampled_data = asizeof.asizeof(resampled_data) / 1024 / 1024
175
+ self.logger.debug("Size of resampled data: %s MB.", size_of_resampled_data)
176
+
177
+ self.logger.debug(
178
+ "Maximum value in resampled data: %s, minimum value: %s. Data type: %s.",
179
+ resampled_data.max(),
180
+ resampled_data.min(),
181
+ resampled_data.dtype,
182
+ )
183
+
184
+ if self.multiplier != 1:
185
+ resampled_data = resampled_data * self.multiplier
186
+
187
+ self.logger.debug(
188
+ "DEM data was multiplied by %s. Min: %s, max: %s. Data type: %s.",
189
+ self.multiplier,
190
+ resampled_data.min(),
191
+ resampled_data.max(),
192
+ resampled_data.dtype,
193
+ )
194
+
195
+ size_of_resampled_data = asizeof.asizeof(resampled_data) / 1024 / 1024
196
+ self.logger.debug("Size of resampled data: %s MB.", size_of_resampled_data)
197
+
198
+ # Clip values to 16-bit unsigned integer range.
199
+ resampled_data = np.clip(resampled_data, 0, 65535)
200
+ resampled_data = resampled_data.astype("uint16")
201
+ self.logger.debug(
202
+ "DEM data was multiplied by %s and clipped to 16-bit unsigned integer range. "
203
+ "Min: %s, max: %s.",
204
+ self.multiplier,
205
+ resampled_data.min(),
206
+ resampled_data.max(),
207
+ )
208
+
209
+ self.logger.debug(
210
+ "DEM data was resampled. Shape: %s, dtype: %s. Min: %s, max: %s.",
211
+ resampled_data.shape,
212
+ resampled_data.dtype,
213
+ resampled_data.min(),
214
+ resampled_data.max(),
215
+ )
216
+
217
+ if self.blur_radius > 0:
218
+ resampled_data = cv2.GaussianBlur(
219
+ resampled_data, (self.blur_radius, self.blur_radius), sigmaX=40, sigmaY=40
220
+ )
221
+ self.logger.debug(
222
+ "Gaussion blur applied to DEM data with kernel size %s.",
223
+ self.blur_radius,
224
+ )
225
+
226
+ self.logger.debug(
227
+ "DEM data was blurred. Shape: %s, dtype: %s. Min: %s, max: %s.",
228
+ resampled_data.shape,
229
+ resampled_data.dtype,
230
+ resampled_data.min(),
231
+ resampled_data.max(),
232
+ )
233
+
234
+ if self.map.dem_settings.plateau:
235
+ # Plateau is a flat area with a constant height.
236
+ # So we just add this value to each pixel of the DEM.
237
+ # And also need to ensure that there will be no values with height greater than
238
+ # it's allowed in 16-bit unsigned integer.
239
+
240
+ resampled_data += self.map.dem_settings.plateau
241
+ resampled_data = np.clip(resampled_data, 0, 65535)
242
+
243
+ self.logger.debug(
244
+ "Plateau with height %s was added to DEM data. Min: %s, max: %s.",
245
+ self.map.dem_settings.plateau,
246
+ resampled_data.min(),
247
+ resampled_data.max(),
248
+ )
249
+
250
+ cv2.imwrite(self._dem_path, resampled_data)
251
+ self.logger.debug("DEM data was saved to %s.", self._dem_path)
252
+
253
+ if self.rotation:
254
+ self.rotate_dem()
255
+
256
+ def rotate_dem(self) -> None:
257
+ """Rotate DEM image."""
258
+ self.logger.debug("Rotating DEM image by %s degrees.", self.rotation)
259
+ output_width, output_height = self.get_output_resolution(use_original=True)
260
+
261
+ self.logger.debug(
262
+ "Output resolution for rotated DEM: %s x %s.", output_width, output_height
263
+ )
264
+
265
+ self.rotate_image(
266
+ self._dem_path,
267
+ self.rotation,
268
+ output_height=output_height,
269
+ output_width=output_width,
270
+ )
271
+
272
+ def _save_empty_dem(self, dem_output_resolution: tuple[int, int]) -> None:
273
+ """Saves empty DEM file filled with zeros."""
274
+ dem_data = np.zeros(dem_output_resolution, dtype="uint16")
275
+ cv2.imwrite(self._dem_path, dem_data)
276
+ self.logger.warning("DEM data filled with zeros and saved to %s.", self._dem_path)
277
+
278
+ def previews(self) -> list:
279
+ """This component does not have previews, returns empty list.
280
+
281
+ Returns:
282
+ list: Empty list.
283
+ """
284
+ return []
285
+
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.
290
+
291
+ Returns:
292
+ dict[Any, Any]: The information sequence for the component.
293
+ """
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
@@ -0,0 +1,71 @@
1
+ """This module contains the base WCS provider."""
2
+
3
+ from abc import abstractmethod
4
+ import os
5
+
6
+ from owslib.wcs import WebCoverageService
7
+ from tqdm import tqdm
8
+
9
+ from maps4fs.generator.dtm import utils
10
+ from maps4fs.generator.dtm.dtm import DTMProvider
11
+
12
+
13
+ # pylint: disable=too-many-locals
14
+ class WCSProvider(DTMProvider):
15
+ """Generic provider of WCS sources."""
16
+
17
+ _is_base = True
18
+ _wcs_version = "2.0.1"
19
+ _source_crs: str = "EPSG:4326"
20
+ _tile_size: float = 0.02
21
+
22
+ @abstractmethod
23
+ def get_wcs_parameters(self, tile: tuple[float, float, float, float]) -> dict:
24
+ """Get the parameters for the WCS request.
25
+
26
+ Arguments:
27
+ tile (tuple): The tile to download.
28
+
29
+ Returns:
30
+ dict: The parameters for the WCS request.
31
+ """
32
+
33
+ def __init__(self, *args, **kwargs):
34
+ super().__init__(*args, **kwargs)
35
+ self.shared_tiff_path = os.path.join(self._tile_directory, "shared")
36
+ os.makedirs(self.shared_tiff_path, exist_ok=True)
37
+
38
+ def download_tiles(self) -> list[str]:
39
+ bbox = self.get_bbox()
40
+ bbox = utils.transform_bbox(bbox, self._source_crs)
41
+ tiles = utils.tile_bbox(bbox, self._tile_size)
42
+
43
+ all_tif_files = self.download_all_tiles(tiles)
44
+ return all_tif_files
45
+
46
+ def download_all_tiles(self, tiles: list[tuple[float, float, float, float]]) -> list[str]:
47
+ """Download tiles from the NI provider.
48
+
49
+ Arguments:
50
+ tiles (list): List of tiles to download.
51
+
52
+ Returns:
53
+ list: List of paths to the downloaded GeoTIFF files.
54
+ """
55
+ all_tif_files = []
56
+ wcs = WebCoverageService(
57
+ self._url,
58
+ version=self._wcs_version,
59
+ # auth=Authentication(verify=False),
60
+ timeout=600,
61
+ )
62
+ for tile in tqdm(tiles, desc="Downloading tiles", unit="tile"):
63
+ file_name = "_".join(map(str, tile)) + ".tif"
64
+ file_path = os.path.join(self.shared_tiff_path, file_name)
65
+ if not os.path.exists(file_path):
66
+ output = wcs.getCoverage(**self.get_wcs_parameters(tile))
67
+ with open(file_path, "wb") as f:
68
+ f.write(output.read())
69
+
70
+ all_tif_files.append(file_path)
71
+ return all_tif_files
@@ -0,0 +1,70 @@
1
+ """This module contains the base WMS provider."""
2
+
3
+ from abc import abstractmethod
4
+ import os
5
+
6
+ from owslib.wms import WebMapService
7
+
8
+ from maps4fs.generator.dtm import utils
9
+ from maps4fs.generator.dtm.dtm import DTMProvider
10
+
11
+
12
+ # pylint: disable=too-many-locals
13
+ class WMSProvider(DTMProvider):
14
+ """Generic provider of WMS sources."""
15
+
16
+ _is_base = True
17
+ _wms_version = "1.3.0"
18
+ _source_crs: str = "EPSG:4326"
19
+ _tile_size: float = 0.02
20
+
21
+ @abstractmethod
22
+ def get_wms_parameters(self, tile: tuple[float, float, float, float]) -> dict:
23
+ """Get the parameters for the WMS request.
24
+
25
+ Arguments:
26
+ tile (tuple): The tile to download.
27
+
28
+ Returns:
29
+ dict: The parameters for the WMS request.
30
+ """
31
+
32
+ def __init__(self, *args, **kwargs):
33
+ super().__init__(*args, **kwargs)
34
+ self.shared_tiff_path = os.path.join(self._tile_directory, "shared")
35
+ os.makedirs(self.shared_tiff_path, exist_ok=True)
36
+
37
+ def download_tiles(self) -> list[str]:
38
+ bbox = self.get_bbox()
39
+ bbox = utils.transform_bbox(bbox, self._source_crs)
40
+ tiles = utils.tile_bbox(bbox, self._tile_size)
41
+
42
+ all_tif_files = self.download_all_tiles(tiles)
43
+ return all_tif_files
44
+
45
+ def download_all_tiles(self, tiles: list[tuple[float, float, float, float]]) -> list[str]:
46
+ """Download tiles from the WMS provider.
47
+
48
+ Arguments:
49
+ tiles (list): List of tiles to download.
50
+
51
+ Returns:
52
+ list: List of paths to the downloaded GeoTIFF files.
53
+ """
54
+ all_tif_files = []
55
+ wms = WebMapService(
56
+ self._url,
57
+ version=self._wms_version,
58
+ # auth=Authentication(verify=False),
59
+ timeout=600,
60
+ )
61
+ for tile in tiles:
62
+ file_name = "_".join(map(str, tile)) + ".tif"
63
+ file_path = os.path.join(self.shared_tiff_path, file_name)
64
+ if not os.path.exists(file_path):
65
+ output = wms.getmap(**self.get_wms_parameters(tile))
66
+ with open(file_path, "wb") as f:
67
+ f.write(output.read())
68
+
69
+ all_tif_files.append(file_path)
70
+ return all_tif_files
@@ -0,0 +1,113 @@
1
+ """This module contains provider of Bavaria data."""
2
+
3
+ import hashlib
4
+ import os
5
+ from xml.etree import ElementTree as ET
6
+
7
+ import requests
8
+
9
+ from maps4fs.generator.dtm.dtm import DTMProvider
10
+
11
+
12
+ class BavariaProvider(DTMProvider):
13
+ """Provider of Bavaria Digital terrain model (DTM) 1m data.
14
+ Data is provided by the 'Bayerische Vermessungsverwaltung' and available
15
+ at https://geodaten.bayern.de/opengeodata/OpenDataDetail.html?pn=dgm1 under CC BY 4.0 license.
16
+ """
17
+
18
+ _code = "bavaria"
19
+ _name = "Bavaria DGM1"
20
+ _region = "DE"
21
+ _icon = "🇩🇪󠁥󠁢󠁹󠁿"
22
+ _resolution = 1
23
+ _author = "[H4rdB4se](https://github.com/H4rdB4se)"
24
+ _is_community = True
25
+ _instructions = None
26
+ _extents = (50.56, 47.25, 13.91, 8.95)
27
+
28
+ def __init__(self, *args, **kwargs):
29
+ super().__init__(*args, **kwargs)
30
+ self.tiff_path = os.path.join(self._tile_directory, "tiffs")
31
+ os.makedirs(self.tiff_path, exist_ok=True)
32
+ self.meta4_path = os.path.join(self._tile_directory, "meta4")
33
+ os.makedirs(self.meta4_path, exist_ok=True)
34
+
35
+ def download_tiles(self) -> list[str]:
36
+ download_urls = self.get_meta_file_from_coords()
37
+ all_tif_files = self.download_tif_files(download_urls, self.tiff_path)
38
+ return all_tif_files
39
+
40
+ @staticmethod
41
+ def get_meta_file_name(north: float, south: float, east: float, west: float) -> str:
42
+ """Generate a hashed file name for the .meta4 file.
43
+
44
+ Arguments:
45
+ north (float): Northern latitude.
46
+ south (float): Southern latitude.
47
+ east (float): Eastern longitude.
48
+ west (float): Western longitude.
49
+
50
+ Returns:
51
+ str: Hashed file name.
52
+ """
53
+ coordinates = f"{north}_{south}_{east}_{west}"
54
+ hash_object = hashlib.md5(coordinates.encode())
55
+ hashed_file_name = "download_" + hash_object.hexdigest() + ".meta4"
56
+ return hashed_file_name
57
+
58
+ def get_meta_file_from_coords(self) -> list[str]:
59
+ """Download .meta4 (xml format) file
60
+
61
+ Returns:
62
+ list: List of download URLs.
63
+ """
64
+ (north, south, east, west) = self.get_bbox()
65
+ file_path = os.path.join(self.meta4_path, self.get_meta_file_name(north, south, east, west))
66
+ if not os.path.exists(file_path):
67
+ try:
68
+ # Make the GET request
69
+ response = requests.post(
70
+ "https://geoservices.bayern.de/services/poly2metalink/metalink/dgm1",
71
+ (
72
+ f"SRID=4326;POLYGON(({west} {south},{east} {south},"
73
+ f"{east} {north},{west} {north},{west} {south}))"
74
+ ),
75
+ stream=True,
76
+ timeout=60,
77
+ )
78
+
79
+ # Check if the request was successful (HTTP status code 200)
80
+ if response.status_code == 200:
81
+ # Write the content of the response to the file
82
+ with open(file_path, "wb") as meta_file:
83
+ for chunk in response.iter_content(chunk_size=8192): # Download in chunks
84
+ meta_file.write(chunk)
85
+ self.logger.debug("File downloaded successfully: %s", file_path)
86
+ else:
87
+ self.logger.error("Download error. HTTP Status Code: %s", response.status_code)
88
+ except requests.exceptions.RequestException as e:
89
+ self.logger.error("Failed to get data. Error: %s", e)
90
+ else:
91
+ self.logger.debug("File already exists: %s", file_path)
92
+ return self.extract_urls_from_xml(file_path)
93
+
94
+ def extract_urls_from_xml(self, file_path: str) -> list[str]:
95
+ """Extract URLs from the XML file.
96
+
97
+ Arguments:
98
+ file_path (str): Path to the XML file.
99
+
100
+ Returns:
101
+ list: List of URLs.
102
+ """
103
+ urls: list[str] = []
104
+ root = ET.parse(file_path).getroot()
105
+ namespace = {"ml": "urn:ietf:params:xml:ns:metalink"}
106
+
107
+ for file in root.findall(".//ml:file", namespace):
108
+ url = file.find("ml:url", namespace)
109
+ if url is not None:
110
+ urls.append(str(url.text))
111
+
112
+ self.logger.debug("Received %s urls", len(urls))
113
+ return urls