maps4fs 1.8.0__py3-none-any.whl
Sign up to get free protection for your applications and to get access to all the features.
- maps4fs/__init__.py +22 -0
- maps4fs/generator/__init__.py +1 -0
- maps4fs/generator/background.py +625 -0
- maps4fs/generator/component.py +553 -0
- maps4fs/generator/config.py +109 -0
- maps4fs/generator/dem.py +297 -0
- maps4fs/generator/dtm/__init__.py +0 -0
- maps4fs/generator/dtm/base/wcs.py +71 -0
- maps4fs/generator/dtm/base/wms.py +70 -0
- maps4fs/generator/dtm/bavaria.py +113 -0
- maps4fs/generator/dtm/dtm.py +637 -0
- maps4fs/generator/dtm/england.py +31 -0
- maps4fs/generator/dtm/hessen.py +31 -0
- maps4fs/generator/dtm/niedersachsen.py +39 -0
- maps4fs/generator/dtm/nrw.py +30 -0
- maps4fs/generator/dtm/srtm.py +127 -0
- maps4fs/generator/dtm/usgs.py +87 -0
- maps4fs/generator/dtm/utils.py +61 -0
- maps4fs/generator/game.py +247 -0
- maps4fs/generator/grle.py +470 -0
- maps4fs/generator/i3d.py +624 -0
- maps4fs/generator/map.py +275 -0
- maps4fs/generator/qgis.py +196 -0
- maps4fs/generator/satellite.py +92 -0
- maps4fs/generator/settings.py +187 -0
- maps4fs/generator/texture.py +893 -0
- maps4fs/logger.py +46 -0
- maps4fs/toolbox/__init__.py +1 -0
- maps4fs/toolbox/background.py +63 -0
- maps4fs/toolbox/custom_osm.py +67 -0
- maps4fs/toolbox/dem.py +112 -0
- maps4fs-1.8.0.dist-info/LICENSE.md +190 -0
- maps4fs-1.8.0.dist-info/METADATA +693 -0
- maps4fs-1.8.0.dist-info/RECORD +36 -0
- maps4fs-1.8.0.dist-info/WHEEL +5 -0
- maps4fs-1.8.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,637 @@
|
|
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 os
|
8
|
+
from abc import ABC, abstractmethod
|
9
|
+
from typing import TYPE_CHECKING, Type
|
10
|
+
from zipfile import ZipFile
|
11
|
+
|
12
|
+
import numpy as np
|
13
|
+
import osmnx as ox # type: ignore
|
14
|
+
import rasterio # type: ignore
|
15
|
+
import requests
|
16
|
+
from pydantic import BaseModel
|
17
|
+
from rasterio.enums import Resampling
|
18
|
+
from rasterio.merge import merge
|
19
|
+
from rasterio.warp import calculate_default_transform, reproject
|
20
|
+
from tqdm import tqdm
|
21
|
+
|
22
|
+
from maps4fs.logger import Logger
|
23
|
+
|
24
|
+
if TYPE_CHECKING:
|
25
|
+
from maps4fs.generator.map import Map
|
26
|
+
|
27
|
+
|
28
|
+
class DTMProviderSettings(BaseModel):
|
29
|
+
"""Base class for DTM provider settings models."""
|
30
|
+
|
31
|
+
easy_mode: bool = True
|
32
|
+
power_factor: int = 0
|
33
|
+
|
34
|
+
|
35
|
+
# pylint: disable=too-many-public-methods, too-many-instance-attributes
|
36
|
+
class DTMProvider(ABC):
|
37
|
+
"""Base class for DTM providers."""
|
38
|
+
|
39
|
+
_code: str | None = None
|
40
|
+
_name: str | None = None
|
41
|
+
_region: str | None = None
|
42
|
+
_icon: str | None = None
|
43
|
+
_resolution: float | str | None = None
|
44
|
+
|
45
|
+
_url: str | None = None
|
46
|
+
|
47
|
+
_author: str | None = None
|
48
|
+
_contributors: str | None = None
|
49
|
+
_is_community: bool = False
|
50
|
+
_is_base: bool = False
|
51
|
+
_settings: Type[DTMProviderSettings] | None = DTMProviderSettings
|
52
|
+
|
53
|
+
"""Bounding box of the provider in the format (north, south, east, west)."""
|
54
|
+
_extents: tuple[float, float, float, float] | None = None
|
55
|
+
|
56
|
+
_instructions: str | None = None
|
57
|
+
|
58
|
+
_base_instructions = (
|
59
|
+
"ℹ️ Using **Easy mode** is recommended, as it automatically adjusts the values in the "
|
60
|
+
"image, so the terrain elevation in Giants Editor will match real world "
|
61
|
+
"elevation in meters. \n"
|
62
|
+
"ℹ️ If the terrain height difference in the real world is bigger than 255 meters, "
|
63
|
+
"the [Height scale](https://github.com/iwatkot/maps4fs/blob/main/docs/dem.md#height-scale)"
|
64
|
+
" parameter in the **map.i3d** file will be automatically adjusted. \n"
|
65
|
+
"⚡ If the **Easy mode** option is disabled, you will probably get completely flat "
|
66
|
+
"terrain, unless you adjust the DEM Multiplier Setting or the Height scale parameter in "
|
67
|
+
"the Giants Editor. \n"
|
68
|
+
"💡 You can use the **Power factor** setting to make the difference between heights "
|
69
|
+
"bigger. Be extremely careful with this setting, and use only low values, otherwise your "
|
70
|
+
"terrain may be completely broken. \n"
|
71
|
+
)
|
72
|
+
|
73
|
+
# pylint: disable=R0913, R0917
|
74
|
+
def __init__(
|
75
|
+
self,
|
76
|
+
coordinates: tuple[float, float],
|
77
|
+
user_settings: DTMProviderSettings | None,
|
78
|
+
size: int,
|
79
|
+
directory: str,
|
80
|
+
logger: Logger,
|
81
|
+
map: Map | None = None, # pylint: disable=W0622
|
82
|
+
):
|
83
|
+
self._coordinates = coordinates
|
84
|
+
self._user_settings = user_settings
|
85
|
+
self._size = size
|
86
|
+
|
87
|
+
if not self._code:
|
88
|
+
raise ValueError("Provider code must be defined.")
|
89
|
+
self._tile_directory = os.path.join(directory, self._code)
|
90
|
+
os.makedirs(self._tile_directory, exist_ok=True)
|
91
|
+
|
92
|
+
self.logger = logger
|
93
|
+
self.map = map
|
94
|
+
|
95
|
+
self._data_info: dict[str, int | str | float] | None = None
|
96
|
+
|
97
|
+
@property
|
98
|
+
def data_info(self) -> dict[str, int | str | float] | None:
|
99
|
+
"""Information about the DTM data.
|
100
|
+
|
101
|
+
Returns:
|
102
|
+
dict: Information about the DTM data.
|
103
|
+
"""
|
104
|
+
return self._data_info
|
105
|
+
|
106
|
+
@data_info.setter
|
107
|
+
def data_info(self, value: dict[str, int | str | float] | None) -> None:
|
108
|
+
"""Set information about the DTM data.
|
109
|
+
|
110
|
+
Arguments:
|
111
|
+
value (dict): Information about the DTM data.
|
112
|
+
"""
|
113
|
+
self._data_info = value
|
114
|
+
|
115
|
+
@property
|
116
|
+
def coordinates(self) -> tuple[float, float]:
|
117
|
+
"""Coordinates of the center point of the DTM data.
|
118
|
+
|
119
|
+
Returns:
|
120
|
+
tuple: Latitude and longitude of the center point.
|
121
|
+
"""
|
122
|
+
return self._coordinates
|
123
|
+
|
124
|
+
@property
|
125
|
+
def size(self) -> int:
|
126
|
+
"""Size of the DTM data in meters.
|
127
|
+
|
128
|
+
Returns:
|
129
|
+
int: Size of the DTM data.
|
130
|
+
"""
|
131
|
+
return self._size
|
132
|
+
|
133
|
+
@property
|
134
|
+
def url(self) -> str | None:
|
135
|
+
"""URL of the provider."""
|
136
|
+
return self._url
|
137
|
+
|
138
|
+
def formatted_url(self, **kwargs) -> str:
|
139
|
+
"""Formatted URL of the provider."""
|
140
|
+
if not self.url:
|
141
|
+
raise ValueError("URL must be defined.")
|
142
|
+
return self.url.format(**kwargs)
|
143
|
+
|
144
|
+
@classmethod
|
145
|
+
def author(cls) -> str | None:
|
146
|
+
"""Author of the provider.
|
147
|
+
|
148
|
+
Returns:
|
149
|
+
str: Author of the provider.
|
150
|
+
"""
|
151
|
+
return cls._author
|
152
|
+
|
153
|
+
@classmethod
|
154
|
+
def contributors(cls) -> str | None:
|
155
|
+
"""Contributors of the provider.
|
156
|
+
|
157
|
+
Returns:
|
158
|
+
str: Contributors of the provider.
|
159
|
+
"""
|
160
|
+
return cls._contributors
|
161
|
+
|
162
|
+
@classmethod
|
163
|
+
def is_base(cls) -> bool:
|
164
|
+
"""Is the provider a base provider.
|
165
|
+
|
166
|
+
Returns:
|
167
|
+
bool: True if the provider is a base provider, False otherwise.
|
168
|
+
"""
|
169
|
+
return cls._is_base
|
170
|
+
|
171
|
+
@classmethod
|
172
|
+
def is_community(cls) -> bool:
|
173
|
+
"""Is the provider a community-driven project.
|
174
|
+
|
175
|
+
Returns:
|
176
|
+
bool: True if the provider is a community-driven project, False otherwise.
|
177
|
+
"""
|
178
|
+
return cls._is_community
|
179
|
+
|
180
|
+
@classmethod
|
181
|
+
def settings(cls) -> Type[DTMProviderSettings] | None:
|
182
|
+
"""Settings model of the provider.
|
183
|
+
|
184
|
+
Returns:
|
185
|
+
Type[DTMProviderSettings]: Settings model of the provider.
|
186
|
+
"""
|
187
|
+
return cls._settings
|
188
|
+
|
189
|
+
@classmethod
|
190
|
+
def instructions(cls) -> str | None:
|
191
|
+
"""Instructions for using the provider.
|
192
|
+
|
193
|
+
Returns:
|
194
|
+
str: Instructions for using the provider.
|
195
|
+
"""
|
196
|
+
return cls._instructions
|
197
|
+
|
198
|
+
@classmethod
|
199
|
+
def base_instructions(cls) -> str | None:
|
200
|
+
"""Instructions for using any provider.
|
201
|
+
|
202
|
+
Returns:
|
203
|
+
str: Instructions for using any provider.
|
204
|
+
"""
|
205
|
+
return cls._base_instructions
|
206
|
+
|
207
|
+
@property
|
208
|
+
def user_settings(self) -> DTMProviderSettings | None:
|
209
|
+
"""User settings of the provider.
|
210
|
+
|
211
|
+
Returns:
|
212
|
+
DTMProviderSettings: User settings of the provider.
|
213
|
+
"""
|
214
|
+
return self._user_settings
|
215
|
+
|
216
|
+
@classmethod
|
217
|
+
def description(cls) -> str:
|
218
|
+
"""Description of the provider.
|
219
|
+
|
220
|
+
Returns:
|
221
|
+
str: Provider description.
|
222
|
+
"""
|
223
|
+
return f"{cls._icon} {cls._region} [{cls._resolution} m/px] {cls._name}"
|
224
|
+
|
225
|
+
@classmethod
|
226
|
+
def get_provider_by_code(cls, code: str) -> Type[DTMProvider] | None:
|
227
|
+
"""Get a provider by its code.
|
228
|
+
|
229
|
+
Arguments:
|
230
|
+
code (str): Provider code.
|
231
|
+
|
232
|
+
Returns:
|
233
|
+
DTMProvider: Provider class or None if not found.
|
234
|
+
"""
|
235
|
+
for provider in cls.__subclasses__():
|
236
|
+
if provider._code == code: # pylint: disable=W0212
|
237
|
+
return provider
|
238
|
+
return None
|
239
|
+
|
240
|
+
@classmethod
|
241
|
+
def get_valid_provider_descriptions(cls, lat_lon: tuple[float, float]) -> dict[str, str]:
|
242
|
+
"""Get descriptions of all providers, where keys are provider codes and
|
243
|
+
values are provider descriptions.
|
244
|
+
|
245
|
+
Returns:
|
246
|
+
dict: Provider descriptions.
|
247
|
+
"""
|
248
|
+
providers = {}
|
249
|
+
for provider in cls.__subclasses__():
|
250
|
+
# pylint: disable=W0212
|
251
|
+
if not provider._is_base and provider.inside_bounding_box(lat_lon):
|
252
|
+
providers[provider._code] = provider.description() # pylint: disable=W0212
|
253
|
+
return providers # type: ignore
|
254
|
+
|
255
|
+
@classmethod
|
256
|
+
def inside_bounding_box(cls, lat_lon: tuple[float, float]) -> bool:
|
257
|
+
"""Check if the coordinates are inside the bounding box of the provider.
|
258
|
+
|
259
|
+
Returns:
|
260
|
+
bool: True if the coordinates are inside the bounding box, False otherwise.
|
261
|
+
"""
|
262
|
+
lat, lon = lat_lon
|
263
|
+
extents = cls._extents
|
264
|
+
return extents is None or (
|
265
|
+
extents[0] >= lat >= extents[1] and extents[2] >= lon >= extents[3]
|
266
|
+
)
|
267
|
+
|
268
|
+
@abstractmethod
|
269
|
+
def download_tiles(self) -> list[str]:
|
270
|
+
"""Download tiles from the provider.
|
271
|
+
|
272
|
+
Returns:
|
273
|
+
list: List of paths to the downloaded tiles.
|
274
|
+
"""
|
275
|
+
raise NotImplementedError
|
276
|
+
|
277
|
+
def get_numpy(self) -> np.ndarray:
|
278
|
+
"""Get numpy array of the tile.
|
279
|
+
Resulting array must be 16 bit (signed or unsigned) integer and it should be already
|
280
|
+
windowed to the bounding box of ROI. It also must have only one channel.
|
281
|
+
|
282
|
+
Returns:
|
283
|
+
np.ndarray: Numpy array of the tile.
|
284
|
+
"""
|
285
|
+
# download tiles using DTM provider implementation
|
286
|
+
tiles = self.download_tiles()
|
287
|
+
self.logger.debug(f"Downloaded {len(tiles)} DEM tiles")
|
288
|
+
|
289
|
+
# merge tiles if necessary
|
290
|
+
if len(tiles) > 1:
|
291
|
+
self.logger.debug("Multiple tiles downloaded. Merging tiles")
|
292
|
+
tile, _ = self.merge_geotiff(tiles)
|
293
|
+
else:
|
294
|
+
tile = tiles[0]
|
295
|
+
|
296
|
+
# determine CRS of the resulting tile and reproject if necessary
|
297
|
+
with rasterio.open(tile) as src:
|
298
|
+
crs = src.crs
|
299
|
+
if crs != "EPSG:4326":
|
300
|
+
print("crs:", crs)
|
301
|
+
print("reprojecting to EPSG:4326")
|
302
|
+
self.logger.debug(f"Reprojecting GeoTIFF from {crs} to EPSG:4326...")
|
303
|
+
tile = self.reproject_geotiff(tile)
|
304
|
+
|
305
|
+
# extract region of interest from the tile
|
306
|
+
data = self.extract_roi(tile)
|
307
|
+
|
308
|
+
# process elevation data to be compatible with the game
|
309
|
+
data = self.process_elevation(data)
|
310
|
+
|
311
|
+
return data
|
312
|
+
|
313
|
+
def process_elevation(self, data: np.ndarray) -> np.ndarray:
|
314
|
+
"""Process elevation data.
|
315
|
+
|
316
|
+
Arguments:
|
317
|
+
data (np.ndarray): Elevation data.
|
318
|
+
|
319
|
+
Returns:
|
320
|
+
np.ndarray: Processed elevation data.
|
321
|
+
"""
|
322
|
+
self.data_info = {}
|
323
|
+
self.add_numpy_params(data, "original")
|
324
|
+
|
325
|
+
data = self.ground_height_data(data)
|
326
|
+
self.add_numpy_params(data, "grounded")
|
327
|
+
|
328
|
+
original_deviation = int(self.data_info["original_deviation"])
|
329
|
+
in_game_maximum_height = 65535 // 255
|
330
|
+
if original_deviation > in_game_maximum_height:
|
331
|
+
suggested_height_scale_multiplier = (
|
332
|
+
original_deviation / in_game_maximum_height # type: ignore
|
333
|
+
)
|
334
|
+
suggested_height_scale_value = int(255 * suggested_height_scale_multiplier)
|
335
|
+
else:
|
336
|
+
suggested_height_scale_multiplier = 1
|
337
|
+
suggested_height_scale_value = 255
|
338
|
+
|
339
|
+
self.data_info["suggested_height_scale_multiplier"] = suggested_height_scale_multiplier
|
340
|
+
self.data_info["suggested_height_scale_value"] = suggested_height_scale_value
|
341
|
+
|
342
|
+
self.map.shared_settings.height_scale_multiplier = ( # type: ignore
|
343
|
+
suggested_height_scale_multiplier
|
344
|
+
)
|
345
|
+
self.map.shared_settings.height_scale_value = suggested_height_scale_value # type: ignore
|
346
|
+
|
347
|
+
if self.user_settings.easy_mode: # type: ignore
|
348
|
+
try:
|
349
|
+
data = self.normalize_dem(data)
|
350
|
+
self.add_numpy_params(data, "normalized")
|
351
|
+
|
352
|
+
normalized_deviation = self.data_info["normalized_deviation"]
|
353
|
+
z_scaling_factor = normalized_deviation / original_deviation # type: ignore
|
354
|
+
self.data_info["z_scaling_factor"] = z_scaling_factor
|
355
|
+
|
356
|
+
self.map.shared_settings.mesh_z_scaling_factor = z_scaling_factor # type: ignore
|
357
|
+
self.map.shared_settings.change_height_scale = True # type: ignore
|
358
|
+
|
359
|
+
except Exception as e: # pylint: disable=W0718
|
360
|
+
self.logger.error(
|
361
|
+
"Failed to normalize DEM data. Error: %s. Using original data.", e
|
362
|
+
)
|
363
|
+
|
364
|
+
return data.astype(np.uint16)
|
365
|
+
|
366
|
+
def info_sequence(self) -> dict[str, int | str | float] | None:
|
367
|
+
"""Returns the information sequence for the component. Must be implemented in the child
|
368
|
+
class. If the component does not have an information sequence, an empty dictionary must be
|
369
|
+
returned.
|
370
|
+
|
371
|
+
Returns:
|
372
|
+
dict[str, int | str | float] | None: Information sequence for the component.
|
373
|
+
"""
|
374
|
+
return self.data_info
|
375
|
+
|
376
|
+
# region helpers
|
377
|
+
def get_bbox(self) -> tuple[float, float, float, float]:
|
378
|
+
"""Get bounding box of the tile based on the center point and size.
|
379
|
+
|
380
|
+
Returns:
|
381
|
+
tuple: Bounding box of the tile (north, south, east, west).
|
382
|
+
"""
|
383
|
+
west, south, east, north = ox.utils_geo.bbox_from_point( # type: ignore
|
384
|
+
self.coordinates, dist=self.size // 2, project_utm=False
|
385
|
+
)
|
386
|
+
bbox = float(north), float(south), float(east), float(west)
|
387
|
+
return bbox
|
388
|
+
|
389
|
+
def download_tif_files(self, urls: list[str], output_path: str) -> list[str]:
|
390
|
+
"""Download GeoTIFF files from the given URLs.
|
391
|
+
|
392
|
+
Arguments:
|
393
|
+
urls (list): List of URLs to download GeoTIFF files from.
|
394
|
+
output_path (str): Path to save the downloaded GeoTIFF files.
|
395
|
+
|
396
|
+
Returns:
|
397
|
+
list: List of paths to the downloaded GeoTIFF files.
|
398
|
+
"""
|
399
|
+
tif_files: list[str] = []
|
400
|
+
for url in tqdm(urls, desc="Downloading tiles", unit="tile"):
|
401
|
+
file_name = os.path.basename(url)
|
402
|
+
self.logger.debug("Retrieving TIFF: %s", file_name)
|
403
|
+
file_path = os.path.join(output_path, file_name)
|
404
|
+
if not os.path.exists(file_path):
|
405
|
+
try:
|
406
|
+
# Send a GET request to the file URL
|
407
|
+
response = requests.get(url, stream=True, timeout=60)
|
408
|
+
response.raise_for_status() # Raise an error for HTTP status codes 4xx/5xx
|
409
|
+
|
410
|
+
# Write the content of the response to the file
|
411
|
+
with open(file_path, "wb") as file:
|
412
|
+
for chunk in response.iter_content(chunk_size=8192): # Download in chunks
|
413
|
+
file.write(chunk)
|
414
|
+
self.logger.debug("File downloaded successfully: %s", file_path)
|
415
|
+
except requests.exceptions.RequestException as e:
|
416
|
+
self.logger.error("Failed to download file: %s", e)
|
417
|
+
else:
|
418
|
+
self.logger.debug("File already exists: %s", file_name)
|
419
|
+
if file_name.endswith(".zip"):
|
420
|
+
file_path = self.unzip_img_from_tif(file_name, output_path)
|
421
|
+
tif_files.append(file_path)
|
422
|
+
return tif_files
|
423
|
+
|
424
|
+
def unzip_img_from_tif(self, file_name: str, output_path: str) -> str:
|
425
|
+
"""Unpacks the .img file from the zip file.
|
426
|
+
|
427
|
+
Arguments:
|
428
|
+
file_name (str): Name of the file to unzip.
|
429
|
+
output_path (str): Path to the output directory.
|
430
|
+
|
431
|
+
Returns:
|
432
|
+
str: Path to the unzipped file.
|
433
|
+
"""
|
434
|
+
file_path = os.path.join(output_path, file_name)
|
435
|
+
img_file_name = file_name.replace(".zip", ".img")
|
436
|
+
img_file_path = os.path.join(output_path, img_file_name)
|
437
|
+
if not os.path.exists(img_file_path):
|
438
|
+
with ZipFile(file_path, "r") as f_in:
|
439
|
+
f_in.extract(img_file_name, output_path)
|
440
|
+
self.logger.debug("Unzipped file %s to %s", file_name, img_file_name)
|
441
|
+
else:
|
442
|
+
self.logger.debug("File already exists: %s", img_file_name)
|
443
|
+
return img_file_path
|
444
|
+
|
445
|
+
def reproject_geotiff(self, input_tiff: str) -> str:
|
446
|
+
"""Reproject a GeoTIFF file to a new coordinate reference system (CRS).
|
447
|
+
|
448
|
+
Arguments:
|
449
|
+
input_tiff (str): Path to the input GeoTIFF file.
|
450
|
+
|
451
|
+
Returns:
|
452
|
+
str: Path to the reprojected GeoTIFF file.
|
453
|
+
"""
|
454
|
+
output_tiff = os.path.join(self._tile_directory, "reprojected.tif")
|
455
|
+
|
456
|
+
# Open the source GeoTIFF
|
457
|
+
self.logger.debug("Reprojecting GeoTIFF to EPSG:4326 CRS...")
|
458
|
+
with rasterio.open(input_tiff) as src:
|
459
|
+
# Get the transform, width, and height of the target CRS
|
460
|
+
transform, width, height = calculate_default_transform(
|
461
|
+
src.crs, "EPSG:4326", src.width, src.height, *src.bounds
|
462
|
+
)
|
463
|
+
|
464
|
+
# Update the metadata for the target GeoTIFF
|
465
|
+
kwargs = src.meta.copy()
|
466
|
+
kwargs.update(
|
467
|
+
{
|
468
|
+
"crs": "EPSG:4326",
|
469
|
+
"transform": transform,
|
470
|
+
"width": width,
|
471
|
+
"height": height,
|
472
|
+
"nodata": None,
|
473
|
+
}
|
474
|
+
)
|
475
|
+
|
476
|
+
# Open the destination GeoTIFF file and reproject
|
477
|
+
with rasterio.open(output_tiff, "w", **kwargs) as dst:
|
478
|
+
for i in range(1, src.count + 1): # Iterate over all raster bands
|
479
|
+
reproject(
|
480
|
+
source=rasterio.band(src, i),
|
481
|
+
destination=rasterio.band(dst, i),
|
482
|
+
src_transform=src.transform,
|
483
|
+
src_crs=src.crs,
|
484
|
+
dst_transform=transform,
|
485
|
+
dst_crs="EPSG:4326",
|
486
|
+
resampling=Resampling.average, # Choose resampling method
|
487
|
+
)
|
488
|
+
|
489
|
+
self.logger.debug("Reprojected GeoTIFF saved to %s", output_tiff)
|
490
|
+
return output_tiff
|
491
|
+
|
492
|
+
def merge_geotiff(self, input_files: list[str]) -> tuple[str, str]:
|
493
|
+
"""Merge multiple GeoTIFF files into a single GeoTIFF file.
|
494
|
+
|
495
|
+
Arguments:
|
496
|
+
input_files (list): List of input GeoTIFF files to merge.
|
497
|
+
"""
|
498
|
+
output_file = os.path.join(self._tile_directory, "merged.tif")
|
499
|
+
# Open all input GeoTIFF files as datasets
|
500
|
+
self.logger.debug("Merging tiff files...")
|
501
|
+
datasets = [rasterio.open(file) for file in input_files]
|
502
|
+
print("datasets:", datasets)
|
503
|
+
|
504
|
+
# Merge datasets
|
505
|
+
crs = datasets[0].crs
|
506
|
+
mosaic, out_transform = merge(datasets, nodata=0)
|
507
|
+
print("mosaic:", mosaic)
|
508
|
+
|
509
|
+
# Get metadata from the first file and update it for the output
|
510
|
+
out_meta = datasets[0].meta.copy()
|
511
|
+
out_meta.update(
|
512
|
+
{
|
513
|
+
"driver": "GTiff",
|
514
|
+
"height": mosaic.shape[1],
|
515
|
+
"width": mosaic.shape[2],
|
516
|
+
"transform": out_transform,
|
517
|
+
"count": mosaic.shape[0], # Number of bands
|
518
|
+
}
|
519
|
+
)
|
520
|
+
|
521
|
+
# Write merged GeoTIFF to the output file
|
522
|
+
with rasterio.open(output_file, "w", **out_meta) as dest:
|
523
|
+
dest.write(mosaic)
|
524
|
+
|
525
|
+
self.logger.debug("GeoTIFF images merged successfully into %s", output_file)
|
526
|
+
return output_file, crs
|
527
|
+
|
528
|
+
def extract_roi(self, tile_path: str) -> np.ndarray:
|
529
|
+
"""Extract region of interest (ROI) from the GeoTIFF file.
|
530
|
+
|
531
|
+
Arguments:
|
532
|
+
tile_path (str): Path to the GeoTIFF file.
|
533
|
+
|
534
|
+
Raises:
|
535
|
+
ValueError: If the tile does not contain any data.
|
536
|
+
|
537
|
+
Returns:
|
538
|
+
np.ndarray: Numpy array of the ROI.
|
539
|
+
"""
|
540
|
+
north, south, east, west = self.get_bbox()
|
541
|
+
with rasterio.open(tile_path) as src:
|
542
|
+
self.logger.debug("Opened tile, shape: %s, dtype: %s.", src.shape, src.dtypes[0])
|
543
|
+
window = rasterio.windows.from_bounds(west, south, east, north, src.transform)
|
544
|
+
self.logger.debug(
|
545
|
+
"Window parameters. Column offset: %s, row offset: %s, width: %s, height: %s.",
|
546
|
+
window.col_off,
|
547
|
+
window.row_off,
|
548
|
+
window.width,
|
549
|
+
window.height,
|
550
|
+
)
|
551
|
+
data = src.read(1, window=window, masked=True)
|
552
|
+
if not data.size > 0:
|
553
|
+
raise ValueError("No data in the tile.")
|
554
|
+
|
555
|
+
return data
|
556
|
+
|
557
|
+
def normalize_dem(self, data: np.ndarray) -> np.ndarray:
|
558
|
+
"""Normalize DEM data to 16-bit unsigned integer using max height from settings.
|
559
|
+
|
560
|
+
Arguments:
|
561
|
+
data (np.ndarray): DEM data from SRTM file after cropping.
|
562
|
+
|
563
|
+
Returns:
|
564
|
+
np.ndarray: Normalized DEM data.
|
565
|
+
"""
|
566
|
+
maximum_height = int(data.max())
|
567
|
+
minimum_height = int(data.min())
|
568
|
+
deviation = maximum_height - minimum_height
|
569
|
+
self.logger.debug(
|
570
|
+
"Maximum height: %s. Minimum height: %s. Deviation: %s.",
|
571
|
+
maximum_height,
|
572
|
+
minimum_height,
|
573
|
+
deviation,
|
574
|
+
)
|
575
|
+
self.logger.debug("Number of unique values in original DEM data: %s.", np.unique(data).size)
|
576
|
+
|
577
|
+
adjusted_maximum_height = maximum_height * 255
|
578
|
+
adjusted_maximum_height = min(adjusted_maximum_height, 65535)
|
579
|
+
scaling_factor = adjusted_maximum_height / maximum_height
|
580
|
+
self.logger.debug(
|
581
|
+
"Adjusted maximum height: %s. Scaling factor: %s.",
|
582
|
+
adjusted_maximum_height,
|
583
|
+
scaling_factor,
|
584
|
+
)
|
585
|
+
|
586
|
+
if self.user_settings.power_factor: # type: ignore
|
587
|
+
power_factor = 1 + self.user_settings.power_factor / 10 # type: ignore
|
588
|
+
self.logger.debug(
|
589
|
+
"Applying power factor: %s to the DEM data.",
|
590
|
+
power_factor,
|
591
|
+
)
|
592
|
+
data = np.power(data, power_factor).astype(np.uint16)
|
593
|
+
|
594
|
+
normalized_data = np.round(data * scaling_factor).astype(np.uint16)
|
595
|
+
self.logger.debug(
|
596
|
+
"Normalized data maximum height: %s. Minimum height: %s. Number of unique values: %s.",
|
597
|
+
normalized_data.max(),
|
598
|
+
normalized_data.min(),
|
599
|
+
np.unique(normalized_data).size,
|
600
|
+
)
|
601
|
+
return normalized_data
|
602
|
+
|
603
|
+
@staticmethod
|
604
|
+
def ground_height_data(data: np.ndarray, add_one: bool = True) -> np.ndarray:
|
605
|
+
"""Shift the data to ground level (0 meter).
|
606
|
+
Optionally add one meter to the data to leave some room
|
607
|
+
for the water level and pit modifications.
|
608
|
+
|
609
|
+
Arguments:
|
610
|
+
data (np.ndarray): DEM data after cropping.
|
611
|
+
add_one (bool): Add one meter to the data
|
612
|
+
|
613
|
+
Returns:
|
614
|
+
np.ndarray: Unsigned DEM data.
|
615
|
+
"""
|
616
|
+
data = data - data.min()
|
617
|
+
if add_one:
|
618
|
+
data = data + 1
|
619
|
+
return data
|
620
|
+
|
621
|
+
def add_numpy_params(
|
622
|
+
self,
|
623
|
+
data: np.ndarray,
|
624
|
+
prefix: str,
|
625
|
+
) -> None:
|
626
|
+
"""Add numpy array parameters to the data_info dictionary.
|
627
|
+
|
628
|
+
Arguments:
|
629
|
+
data (np.ndarray): Numpy array of the tile.
|
630
|
+
prefix (str): Prefix for the parameters.
|
631
|
+
"""
|
632
|
+
self.data_info[f"{prefix}_minimum_height"] = int(data.min()) # type: ignore
|
633
|
+
self.data_info[f"{prefix}_maximum_height"] = int(data.max()) # type: ignore
|
634
|
+
self.data_info[f"{prefix}_deviation"] = int(data.max() - data.min()) # type: ignore
|
635
|
+
self.data_info[f"{prefix}_unique_values"] = int(np.unique(data).size) # type: ignore
|
636
|
+
|
637
|
+
# endregion
|
@@ -0,0 +1,31 @@
|
|
1
|
+
"""This module contains provider of England data."""
|
2
|
+
|
3
|
+
from maps4fs.generator.dtm.base.wcs import WCSProvider
|
4
|
+
from maps4fs.generator.dtm.dtm import DTMProvider
|
5
|
+
|
6
|
+
|
7
|
+
class England1MProvider(WCSProvider, DTMProvider):
|
8
|
+
"""Provider of England data."""
|
9
|
+
|
10
|
+
_code = "england1m"
|
11
|
+
_name = "England DGM1"
|
12
|
+
_region = "UK"
|
13
|
+
_icon = "🏴"
|
14
|
+
_resolution = 1
|
15
|
+
_author = "[kbrandwijk](https://github.com/kbrandwijk)"
|
16
|
+
_is_community = True
|
17
|
+
_instructions = None
|
18
|
+
_is_base = False
|
19
|
+
_extents = (55.87708724246775, 49.85060473351981, 2.0842821419111135, -7.104775741839742)
|
20
|
+
|
21
|
+
_url = "https://environment.data.gov.uk/geoservices/datasets/13787b9a-26a4-4775-8523-806d13af58fc/wcs" # pylint: disable=line-too-long
|
22
|
+
_wcs_version = "2.0.1"
|
23
|
+
_source_crs = "EPSG:27700"
|
24
|
+
_tile_size = 1000
|
25
|
+
|
26
|
+
def get_wcs_parameters(self, tile):
|
27
|
+
return {
|
28
|
+
"identifier": ["13787b9a-26a4-4775-8523-806d13af58fc:Lidar_Composite_Elevation_DTM_1m"],
|
29
|
+
"subsets": [("E", str(tile[1]), str(tile[3])), ("N", str(tile[0]), str(tile[2]))],
|
30
|
+
"format": "tiff",
|
31
|
+
}
|
@@ -0,0 +1,31 @@
|
|
1
|
+
"""This module contains provider of Hessen data."""
|
2
|
+
|
3
|
+
from maps4fs.generator.dtm.base.wcs import WCSProvider
|
4
|
+
from maps4fs.generator.dtm.dtm import DTMProvider
|
5
|
+
|
6
|
+
|
7
|
+
class HessenProvider(WCSProvider, DTMProvider):
|
8
|
+
"""Provider of Hessen data."""
|
9
|
+
|
10
|
+
_code = "hessen"
|
11
|
+
_name = "Hessen DGM1"
|
12
|
+
_region = "DE"
|
13
|
+
_icon = "🇩🇪"
|
14
|
+
_resolution = 1
|
15
|
+
_author = "[kbrandwijk](https://github.com/kbrandwijk)"
|
16
|
+
_is_community = True
|
17
|
+
_is_base = False
|
18
|
+
_extents = (51.66698, 49.38533, 10.25780, 7.72773)
|
19
|
+
|
20
|
+
_url = "https://inspire-hessen.de/raster/dgm1/ows"
|
21
|
+
_wcs_version = "2.0.1"
|
22
|
+
_source_crs = "EPSG:25832"
|
23
|
+
_tile_size = 1000
|
24
|
+
|
25
|
+
def get_wcs_parameters(self, tile: tuple[float, float, float, float]) -> dict:
|
26
|
+
return {
|
27
|
+
"identifier": ["he_dgm1"],
|
28
|
+
"subsets": [("N", str(tile[0]), str(tile[2])), ("E", str(tile[1]), str(tile[3]))],
|
29
|
+
"format": "image/gtiff",
|
30
|
+
"timeout": 600,
|
31
|
+
}
|