maps4fs 1.5.0__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/generator/dem.py CHANGED
@@ -1,19 +1,16 @@
1
1
  """This module contains DEM class for processing Digital Elevation Model data."""
2
2
 
3
- import gzip
4
- import math
5
3
  import os
6
- import shutil
4
+ from typing import Any
7
5
 
8
6
  import cv2
9
7
  import numpy as np
10
- import rasterio # type: ignore
11
- import requests
8
+
9
+ # import rasterio # type: ignore
12
10
  from pympler import asizeof # type: ignore
13
11
 
14
12
  from maps4fs.generator.component import Component
15
-
16
- SRTM = "https://elevation-tiles-prod.s3.amazonaws.com/skadi/{latitude_band}/{tile_name}.hgt.gz"
13
+ from maps4fs.generator.dtm.dtm import DTMProvider
17
14
 
18
15
 
19
16
  # pylint: disable=R0903, R0902
@@ -61,7 +58,14 @@ class DEM(Component):
61
58
  self.blur_radius,
62
59
  )
63
60
 
64
- self.auto_process = self.map.dem_settings.auto_process
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
+ )
65
69
 
66
70
  @property
67
71
  def dem_path(self) -> str:
@@ -132,36 +136,29 @@ class DEM(Component):
132
136
  def process(self) -> None:
133
137
  """Reads SRTM file, crops it to map size, normalizes and blurs it,
134
138
  saves to map directory."""
135
- north, south, east, west = self.bbox
136
139
 
137
140
  dem_output_resolution = self.output_resolution
138
141
  self.logger.debug("DEM output resolution: %s.", dem_output_resolution)
139
142
 
140
- tile_path = self._srtm_tile()
141
- if not tile_path:
142
- self.logger.warning("Tile was not downloaded, DEM file will be filled with zeros.")
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)
143
147
  self._save_empty_dem(dem_output_resolution)
144
148
  return
145
149
 
146
- with rasterio.open(tile_path) as src:
147
- self.logger.debug("Opened tile, shape: %s, dtype: %s.", src.shape, src.dtypes[0])
148
- window = rasterio.windows.from_bounds(west, south, east, north, src.transform)
149
- self.logger.debug(
150
- "Window parameters. Column offset: %s, row offset: %s, width: %s, height: %s.",
151
- window.col_off,
152
- window.row_off,
153
- window.width,
154
- window.height,
155
- )
156
- data = src.read(1, window=window)
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
157
154
 
158
- if not data.size > 0:
159
- self.logger.warning("DEM data is empty, DEM file will be filled with zeros.")
155
+ if data.dtype not in ["int16", "uint16"]:
156
+ self.logger.error("DTM provider returned incorrect data type: %s.", data.dtype)
160
157
  self._save_empty_dem(dem_output_resolution)
161
158
  return
162
159
 
163
160
  self.logger.debug(
164
- "DEM data was read from SRTM file. Shape: %s, dtype: %s. Min: %s, max: %s.",
161
+ "DEM data was retrieved from DTM provider. Shape: %s, dtype: %s. Min: %s, max: %s.",
165
162
  data.shape,
166
163
  data.dtype,
167
164
  data.min(),
@@ -184,11 +181,7 @@ class DEM(Component):
184
181
  resampled_data.dtype,
185
182
  )
186
183
 
187
- if self.auto_process:
188
- self.logger.debug("Auto processing is enabled, will normalize DEM data.")
189
- resampled_data = self._normalize_dem(resampled_data)
190
- else:
191
- self.logger.debug("Auto processing is disabled, DEM data will not be normalized.")
184
+ if self.multiplier != 1:
192
185
  resampled_data = resampled_data * self.multiplier
193
186
 
194
187
  self.logger.debug(
@@ -276,81 +269,6 @@ class DEM(Component):
276
269
  output_width=output_width,
277
270
  )
278
271
 
279
- def _tile_info(self, lat: float, lon: float) -> tuple[str, str]:
280
- """Returns latitude band and tile name for SRTM tile from coordinates.
281
-
282
- Arguments:
283
- lat (float): Latitude.
284
- lon (float): Longitude.
285
-
286
- Returns:
287
- tuple[str, str]: Latitude band and tile name.
288
- """
289
- tile_latitude = math.floor(lat)
290
- tile_longitude = math.floor(lon)
291
-
292
- latitude_band = f"N{abs(tile_latitude):02d}" if lat >= 0 else f"S{abs(tile_latitude):02d}"
293
- if lon < 0:
294
- tile_name = f"{latitude_band}W{abs(tile_longitude):03d}"
295
- else:
296
- tile_name = f"{latitude_band}E{abs(tile_longitude):03d}"
297
-
298
- self.logger.debug(
299
- "Detected tile name: %s for coordinates: lat %s, lon %s.", tile_name, lat, lon
300
- )
301
- return latitude_band, tile_name
302
-
303
- def _download_tile(self) -> str | None:
304
- """Downloads SRTM tile from Amazon S3 using coordinates.
305
-
306
- Returns:
307
- str: Path to compressed tile or None if download failed.
308
- """
309
- latitude_band, tile_name = self._tile_info(*self.coordinates)
310
- compressed_file_path = os.path.join(self.gz_dir, f"{tile_name}.hgt.gz")
311
- url = SRTM.format(latitude_band=latitude_band, tile_name=tile_name)
312
- self.logger.debug("Trying to get response from %s...", url)
313
- response = requests.get(url, stream=True, timeout=10)
314
-
315
- if response.status_code == 200:
316
- self.logger.debug("Response received. Saving to %s...", compressed_file_path)
317
- with open(compressed_file_path, "wb") as f:
318
- for chunk in response.iter_content(chunk_size=8192):
319
- f.write(chunk)
320
- self.logger.debug("Compressed tile successfully downloaded.")
321
- else:
322
- self.logger.error("Response was failed with status code %s.", response.status_code)
323
- return None
324
-
325
- return compressed_file_path
326
-
327
- def _srtm_tile(self) -> str | None:
328
- """Determines SRTM tile name from coordinates downloads it if necessary, and decompresses.
329
-
330
- Returns:
331
- str: Path to decompressed tile or None if download failed.
332
- """
333
- latitude_band, tile_name = self._tile_info(*self.coordinates)
334
- self.logger.debug("SRTM tile name %s from latitude band %s.", tile_name, latitude_band)
335
-
336
- decompressed_file_path = os.path.join(self.hgt_dir, f"{tile_name}.hgt")
337
- if os.path.isfile(decompressed_file_path):
338
- self.logger.debug(
339
- "Decompressed tile already exists: %s, skipping download.",
340
- decompressed_file_path,
341
- )
342
- return decompressed_file_path
343
-
344
- compressed_file_path = self._download_tile()
345
- if not compressed_file_path:
346
- self.logger.error("Download from SRTM failed, DEM file will be filled with zeros.")
347
- return None
348
- with gzip.open(compressed_file_path, "rb") as f_in:
349
- with open(decompressed_file_path, "wb") as f_out:
350
- shutil.copyfileobj(f_in, f_out)
351
- self.logger.debug("Tile decompressed to %s.", decompressed_file_path)
352
- return decompressed_file_path
353
-
354
272
  def _save_empty_dem(self, dem_output_resolution: tuple[int, int]) -> None:
355
273
  """Saves empty DEM file filled with zeros."""
356
274
  dem_data = np.zeros(dem_output_resolution, dtype="uint16")
@@ -365,54 +283,15 @@ class DEM(Component):
365
283
  """
366
284
  return []
367
285
 
368
- def _get_scaling_factor(self, maximum_deviation: int) -> float:
369
- """Calculate scaling factor for DEM data normalization.
370
- NOTE: Needs reconsideration for the implementation.
371
-
372
- Arguments:
373
- maximum_deviation (int): Maximum deviation in DEM data.
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.
374
290
 
375
291
  Returns:
376
- float: Scaling factor for DEM data normalization.
292
+ dict[Any, Any]: The information sequence for the component.
377
293
  """
378
- ESTIMATED_MAXIMUM_DEVIATION = 1000 # pylint: disable=C0103
379
- scaling_factor = maximum_deviation / ESTIMATED_MAXIMUM_DEVIATION
380
- return scaling_factor if scaling_factor < 1 else 1
381
-
382
- def _normalize_dem(self, data: np.ndarray) -> np.ndarray:
383
- """Normalize DEM data to 16-bit unsigned integer using max height from settings.
384
- Arguments:
385
- data (np.ndarray): DEM data from SRTM file after cropping.
386
- Returns:
387
- np.ndarray: Normalized DEM data.
388
- """
389
- self.logger.debug("Starting DEM data normalization.")
390
- # Calculate the difference between the maximum and minimum values in the DEM data.
391
-
392
- max_height = data.max()
393
- min_height = data.min()
394
- max_dev = max_height - min_height
395
- self.logger.debug(
396
- "Maximum deviation: %s with maximum at %s and minimum at %s.",
397
- max_dev,
398
- max_height,
399
- min_height,
400
- )
401
-
402
- scaling_factor = self._get_scaling_factor(max_dev)
403
- adjusted_max_height = int(65535 * scaling_factor)
404
- self.logger.debug(
405
- "Maximum deviation: %s. Scaling factor: %s. Adjusted max height: %s.",
406
- max_dev,
407
- scaling_factor,
408
- adjusted_max_height,
409
- )
410
- normalized_data = (
411
- (data - data.min()) / (data.max() - data.min()) * adjusted_max_height
412
- ).astype("uint16")
413
- self.logger.debug(
414
- "DEM data was normalized to %s - %s.",
415
- normalized_data.min(),
416
- normalized_data.max(),
417
- )
418
- 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
@@ -0,0 +1,321 @@
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
+ from abc import ABC, abstractmethod
8
+ import os
9
+ from typing import TYPE_CHECKING, Type
10
+
11
+ import numpy as np
12
+ import osmnx as ox # type: ignore
13
+ import rasterio # type: ignore
14
+ import requests
15
+ from pydantic import BaseModel
16
+
17
+ from maps4fs.logger import Logger
18
+
19
+ if TYPE_CHECKING:
20
+ from maps4fs.generator.map import Map
21
+
22
+
23
+ class DTMProviderSettings(BaseModel):
24
+ """Base class for DTM provider settings models."""
25
+
26
+
27
+ # pylint: disable=too-many-public-methods
28
+ class DTMProvider(ABC):
29
+ """Base class for DTM providers."""
30
+
31
+ _code: str | None = None
32
+ _name: str | None = None
33
+ _region: str | None = None
34
+ _icon: str | None = None
35
+ _resolution: float | str | None = None
36
+
37
+ _url: str | None = None
38
+
39
+ _author: str | None = None
40
+ _contributors: str | None = None
41
+ _is_community: bool = False
42
+ _is_base: bool = False
43
+ _settings: Type[DTMProviderSettings] | None = None
44
+
45
+ _instructions: str | None = None
46
+
47
+ # pylint: disable=R0913, R0917
48
+ def __init__(
49
+ self,
50
+ coordinates: tuple[float, float],
51
+ user_settings: DTMProviderSettings | None,
52
+ size: int,
53
+ directory: str,
54
+ logger: Logger,
55
+ map: Map | None = None, # pylint: disable=W0622
56
+ ):
57
+ self._coordinates = coordinates
58
+ self._user_settings = user_settings
59
+ self._size = size
60
+
61
+ if not self._code:
62
+ raise ValueError("Provider code must be defined.")
63
+ self._tile_directory = os.path.join(directory, self._code)
64
+ os.makedirs(self._tile_directory, exist_ok=True)
65
+
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
88
+
89
+ @property
90
+ def coordinates(self) -> tuple[float, float]:
91
+ """Coordinates of the center point of the DTM data.
92
+
93
+ Returns:
94
+ tuple: Latitude and longitude of the center point.
95
+ """
96
+ return self._coordinates
97
+
98
+ @property
99
+ def size(self) -> int:
100
+ """Size of the DTM data in meters.
101
+
102
+ Returns:
103
+ int: Size of the DTM data.
104
+ """
105
+ return self._size
106
+
107
+ @property
108
+ def url(self) -> str | None:
109
+ """URL of the provider."""
110
+ return self._url
111
+
112
+ def formatted_url(self, **kwargs) -> str:
113
+ """Formatted URL of the provider."""
114
+ if not self.url:
115
+ raise ValueError("URL must be defined.")
116
+ return self.url.format(**kwargs)
117
+
118
+ @classmethod
119
+ def author(cls) -> str | None:
120
+ """Author of the provider.
121
+
122
+ Returns:
123
+ str: Author of the provider.
124
+ """
125
+ return cls._author
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
+
145
+ @classmethod
146
+ def is_community(cls) -> bool:
147
+ """Is the provider a community-driven project.
148
+
149
+ Returns:
150
+ bool: True if the provider is a community-driven project, False otherwise.
151
+ """
152
+ return cls._is_community
153
+
154
+ @classmethod
155
+ def settings(cls) -> Type[DTMProviderSettings] | None:
156
+ """Settings model of the provider.
157
+
158
+ Returns:
159
+ Type[DTMProviderSettings]: Settings model of the provider.
160
+ """
161
+ return cls._settings
162
+
163
+ @classmethod
164
+ def instructions(cls) -> str | None:
165
+ """Instructions for using the provider.
166
+
167
+ Returns:
168
+ str: Instructions for using the provider.
169
+ """
170
+ return cls._instructions
171
+
172
+ @property
173
+ def user_settings(self) -> DTMProviderSettings | None:
174
+ """User settings of the provider.
175
+
176
+ Returns:
177
+ DTMProviderSettings: User settings of the provider.
178
+ """
179
+ return self._user_settings
180
+
181
+ @classmethod
182
+ def description(cls) -> str:
183
+ """Description of the provider.
184
+
185
+ Returns:
186
+ str: Provider description.
187
+ """
188
+ return f"{cls._icon} {cls._region} [{cls._resolution} m/px] {cls._name}"
189
+
190
+ @classmethod
191
+ def get_provider_by_code(cls, code: str) -> Type[DTMProvider] | None:
192
+ """Get a provider by its code.
193
+
194
+ Arguments:
195
+ code (str): Provider code.
196
+
197
+ Returns:
198
+ DTMProvider: Provider class or None if not found.
199
+ """
200
+ for provider in cls.__subclasses__():
201
+ if provider._code == code: # pylint: disable=W0212
202
+ return provider
203
+ return None
204
+
205
+ @classmethod
206
+ def get_provider_descriptions(cls) -> dict[str, str]:
207
+ """Get descriptions of all providers, where keys are provider codes and
208
+ values are provider descriptions.
209
+
210
+ Returns:
211
+ dict: Provider descriptions.
212
+ """
213
+ providers = {}
214
+ for provider in cls.__subclasses__():
215
+ if not provider.is_base():
216
+ providers[provider._code] = provider.description() # pylint: disable=W0212
217
+ return providers # type: ignore
218
+
219
+ def download_tile(self, output_path: str, **kwargs) -> bool:
220
+ """Download a tile from the provider.
221
+
222
+ Arguments:
223
+ output_path (str): Path to save the downloaded tile.
224
+
225
+ Returns:
226
+ bool: True if the tile was downloaded successfully, False otherwise.
227
+ """
228
+ url = self.formatted_url(**kwargs)
229
+ response = requests.get(url, stream=True, timeout=10)
230
+ if response.status_code == 200:
231
+ with open(output_path, "wb") as file:
232
+ for chunk in response.iter_content(chunk_size=1024):
233
+ file.write(chunk)
234
+ return True
235
+ return False
236
+
237
+ def get_or_download_tile(self, output_path: str, **kwargs) -> str | None:
238
+ """Get or download a tile from the provider.
239
+
240
+ Arguments:
241
+ output_path (str): Path to save the downloaded tile.
242
+
243
+ Returns:
244
+ str: Path to the downloaded tile or None if the tile not exists and was
245
+ not downloaded.
246
+ """
247
+ if not os.path.exists(output_path):
248
+ if not self.download_tile(output_path, **kwargs):
249
+ return None
250
+ return output_path
251
+
252
+ def get_tile_parameters(self, *args, **kwargs) -> dict:
253
+ """Get parameters for the tile, that will be used to format the URL.
254
+ Must be implemented in subclasses.
255
+
256
+ Returns:
257
+ dict: Tile parameters to format the URL.
258
+ """
259
+ raise NotImplementedError
260
+
261
+ @abstractmethod
262
+ def get_numpy(self) -> np.ndarray:
263
+ """Get numpy array of the tile.
264
+ Resulting array must be 16 bit (signed or unsigned) integer and it should be already
265
+ windowed to the bounding box of ROI. It also must have only one channel.
266
+
267
+ Returns:
268
+ np.ndarray: Numpy array of the tile.
269
+ """
270
+ raise NotImplementedError
271
+
272
+ def get_bbox(self) -> tuple[float, float, float, float]:
273
+ """Get bounding box of the tile based on the center point and size.
274
+
275
+ Returns:
276
+ tuple: Bounding box of the tile (north, south, east, west).
277
+ """
278
+ west, south, east, north = ox.utils_geo.bbox_from_point( # type: ignore
279
+ self.coordinates, dist=self.size // 2, project_utm=False
280
+ )
281
+ bbox = north, south, east, west
282
+ return bbox
283
+
284
+ def extract_roi(self, tile_path: str) -> np.ndarray:
285
+ """Extract region of interest (ROI) from the GeoTIFF file.
286
+
287
+ Arguments:
288
+ tile_path (str): Path to the GeoTIFF file.
289
+
290
+ Raises:
291
+ ValueError: If the tile does not contain any data.
292
+
293
+ Returns:
294
+ np.ndarray: Numpy array of the ROI.
295
+ """
296
+ north, south, east, west = self.get_bbox()
297
+ with rasterio.open(tile_path) as src:
298
+ self.logger.debug("Opened tile, shape: %s, dtype: %s.", src.shape, src.dtypes[0])
299
+ window = rasterio.windows.from_bounds(west, south, east, north, src.transform)
300
+ self.logger.debug(
301
+ "Window parameters. Column offset: %s, row offset: %s, width: %s, height: %s.",
302
+ window.col_off,
303
+ window.row_off,
304
+ window.width,
305
+ window.height,
306
+ )
307
+ data = src.read(1, window=window)
308
+ if not data.size > 0:
309
+ raise ValueError("No data in the tile.")
310
+
311
+ return data
312
+
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.
317
+
318
+ Returns:
319
+ dict[str, int | str | float] | None: Information sequence for the component.
320
+ """
321
+ return self.data_info