maps4fs 1.7.1__py3-none-any.whl → 1.7.6__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.
Potentially problematic release.
This version of maps4fs might be problematic. Click here for more details.
- maps4fs/__init__.py +5 -2
- maps4fs/generator/dtm/bavaria.py +115 -0
- maps4fs/generator/dtm/dtm.py +335 -47
- maps4fs/generator/dtm/nrw.py +127 -0
- maps4fs/generator/dtm/srtm.py +59 -161
- maps4fs/generator/dtm/usgs.py +6 -268
- maps4fs/generator/texture.py +9 -2
- maps4fs/toolbox/custom_osm.py +67 -0
- {maps4fs-1.7.1.dist-info → maps4fs-1.7.6.dist-info}/METADATA +51 -21
- {maps4fs-1.7.1.dist-info → maps4fs-1.7.6.dist-info}/RECORD +13 -10
- {maps4fs-1.7.1.dist-info → maps4fs-1.7.6.dist-info}/LICENSE.md +0 -0
- {maps4fs-1.7.1.dist-info → maps4fs-1.7.6.dist-info}/WHEEL +0 -0
- {maps4fs-1.7.1.dist-info → maps4fs-1.7.6.dist-info}/top_level.txt +0 -0
maps4fs/__init__.py
CHANGED
@@ -1,7 +1,9 @@
|
|
1
1
|
# pylint: disable=missing-module-docstring
|
2
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
|
+
from maps4fs.generator.dtm.srtm import SRTM30Provider, SRTM30ProviderSettings
|
4
|
+
from maps4fs.generator.dtm.usgs import USGSProvider, USGSProviderSettings
|
5
|
+
from maps4fs.generator.dtm.nrw import NRWProvider, NRWProviderSettings
|
6
|
+
from maps4fs.generator.dtm.bavaria import BavariaProvider, BavariaProviderSettings
|
5
7
|
from maps4fs.generator.game import Game
|
6
8
|
from maps4fs.generator.map import Map
|
7
9
|
from maps4fs.generator.settings import (
|
@@ -9,6 +11,7 @@ from maps4fs.generator.settings import (
|
|
9
11
|
DEMSettings,
|
10
12
|
GRLESettings,
|
11
13
|
I3DSettings,
|
14
|
+
SatelliteSettings,
|
12
15
|
SettingsModel,
|
13
16
|
SplineSettings,
|
14
17
|
TextureSettings,
|
@@ -0,0 +1,115 @@
|
|
1
|
+
"""This module contains provider of Bavaria data."""
|
2
|
+
|
3
|
+
import os
|
4
|
+
|
5
|
+
from xml.etree import ElementTree as ET
|
6
|
+
import hashlib
|
7
|
+
import numpy as np
|
8
|
+
import requests
|
9
|
+
from maps4fs.generator.dtm.dtm import DTMProvider, DTMProviderSettings
|
10
|
+
|
11
|
+
class BavariaProviderSettings(DTMProviderSettings):
|
12
|
+
"""Settings for the Bavaria provider."""
|
13
|
+
|
14
|
+
|
15
|
+
class BavariaProvider(DTMProvider):
|
16
|
+
"""Provider of Bavaria Digital terrain model (DTM) 1m data.
|
17
|
+
Data is provided by the 'Bayerische Vermessungsverwaltung' and available
|
18
|
+
at https://geodaten.bayern.de/opengeodata/OpenDataDetail.html?pn=dgm1 under CC BY 4.0 license.
|
19
|
+
"""
|
20
|
+
|
21
|
+
_code = "bavaria"
|
22
|
+
_name = "Bavaria DGM1"
|
23
|
+
_region = "DE"
|
24
|
+
_icon = "🇩🇪"
|
25
|
+
_resolution = 1
|
26
|
+
_data: np.ndarray | None = None
|
27
|
+
_settings = BavariaProviderSettings
|
28
|
+
_author = "[H4rdB4se](https://github.com/H4rdB4se)"
|
29
|
+
_is_community = True
|
30
|
+
_instructions = None
|
31
|
+
|
32
|
+
def __init__(self, *args, **kwargs):
|
33
|
+
super().__init__(*args, **kwargs)
|
34
|
+
self.tiff_path = os.path.join(self._tile_directory, "tiffs")
|
35
|
+
os.makedirs(self.tiff_path, exist_ok=True)
|
36
|
+
self.meta4_path = os.path.join(self._tile_directory, "meta4")
|
37
|
+
os.makedirs(self.meta4_path, exist_ok=True)
|
38
|
+
|
39
|
+
def download_tiles(self) -> list[str]:
|
40
|
+
download_urls = self.get_meta_file_from_coords()
|
41
|
+
all_tif_files = self.download_tif_files(download_urls, self.tiff_path)
|
42
|
+
return all_tif_files
|
43
|
+
|
44
|
+
@staticmethod
|
45
|
+
def get_meta_file_name(north: float, south: float, east: float, west: float) -> str:
|
46
|
+
"""Generate a hashed file name for the .meta4 file.
|
47
|
+
|
48
|
+
Arguments:
|
49
|
+
north (float): Northern latitude.
|
50
|
+
south (float): Southern latitude.
|
51
|
+
east (float): Eastern longitude.
|
52
|
+
west (float): Western longitude.
|
53
|
+
|
54
|
+
Returns:
|
55
|
+
str: Hashed file name.
|
56
|
+
"""
|
57
|
+
coordinates = f"{north}_{south}_{east}_{west}"
|
58
|
+
hash_object = hashlib.md5(coordinates.encode())
|
59
|
+
hashed_file_name = "download_" + hash_object.hexdigest() + ".meta4"
|
60
|
+
return hashed_file_name
|
61
|
+
|
62
|
+
def get_meta_file_from_coords(self) -> list[str]:
|
63
|
+
"""Download .meta4 (xml format) file
|
64
|
+
|
65
|
+
Returns:
|
66
|
+
list: List of download URLs.
|
67
|
+
"""
|
68
|
+
(north, south, east, west) = self.get_bbox()
|
69
|
+
file_path = os.path.join(self.meta4_path, self.get_meta_file_name(north, south, east, west))
|
70
|
+
if not os.path.exists(file_path):
|
71
|
+
try:
|
72
|
+
# Make the GET request
|
73
|
+
response = requests.post(
|
74
|
+
"https://geoservices.bayern.de/services/poly2metalink/metalink/dgm1",
|
75
|
+
(f"SRID=4326;POLYGON(({west} {south},{east} {south},"
|
76
|
+
f"{east} {north},{west} {north},{west} {south}))"),
|
77
|
+
stream=True,
|
78
|
+
timeout=60
|
79
|
+
)
|
80
|
+
|
81
|
+
# Check if the request was successful (HTTP status code 200)
|
82
|
+
if response.status_code == 200:
|
83
|
+
# Write the content of the response to the file
|
84
|
+
with open(file_path, "wb") as meta_file:
|
85
|
+
for chunk in response.iter_content(chunk_size=8192): # Download in chunks
|
86
|
+
meta_file.write(chunk)
|
87
|
+
self.logger.info("File downloaded successfully: %s", file_path)
|
88
|
+
else:
|
89
|
+
self.logger.error("Download error. HTTP Status Code: %s", response.status_code)
|
90
|
+
except requests.exceptions.RequestException as e:
|
91
|
+
self.logger.error("Failed to get data. Error: %s", e)
|
92
|
+
else:
|
93
|
+
self.logger.debug("File already exists: %s", file_path)
|
94
|
+
return self.extract_urls_from_xml(file_path)
|
95
|
+
|
96
|
+
def extract_urls_from_xml(self, file_path: str) -> list[str]:
|
97
|
+
"""Extract URLs from the XML file.
|
98
|
+
|
99
|
+
Arguments:
|
100
|
+
file_path (str): Path to the XML file.
|
101
|
+
|
102
|
+
Returns:
|
103
|
+
list: List of URLs.
|
104
|
+
"""
|
105
|
+
urls: list[str] = []
|
106
|
+
root = ET.parse(file_path).getroot()
|
107
|
+
namespace = {'ml': 'urn:ietf:params:xml:ns:metalink'}
|
108
|
+
|
109
|
+
for file in root.findall('.//ml:file', namespace):
|
110
|
+
url = file.find('ml:url', namespace)
|
111
|
+
if url is not None:
|
112
|
+
urls.append(str(url.text))
|
113
|
+
|
114
|
+
self.logger.debug("Received %s urls", len(urls))
|
115
|
+
return urls
|
maps4fs/generator/dtm/dtm.py
CHANGED
@@ -7,11 +7,16 @@ from __future__ import annotations
|
|
7
7
|
from abc import ABC, abstractmethod
|
8
8
|
import os
|
9
9
|
from typing import TYPE_CHECKING, Type
|
10
|
+
from zipfile import ZipFile
|
10
11
|
|
11
12
|
import numpy as np
|
12
13
|
import osmnx as ox # type: ignore
|
13
14
|
import rasterio # type: ignore
|
14
15
|
import requests
|
16
|
+
from rasterio.warp import calculate_default_transform, reproject
|
17
|
+
from rasterio.enums import Resampling
|
18
|
+
from rasterio.merge import merge
|
19
|
+
|
15
20
|
from pydantic import BaseModel
|
16
21
|
|
17
22
|
from maps4fs.logger import Logger
|
@@ -23,8 +28,11 @@ if TYPE_CHECKING:
|
|
23
28
|
class DTMProviderSettings(BaseModel):
|
24
29
|
"""Base class for DTM provider settings models."""
|
25
30
|
|
31
|
+
easy_mode: bool = True
|
32
|
+
power_factor: int = 0
|
33
|
+
|
26
34
|
|
27
|
-
# pylint: disable=too-many-public-methods
|
35
|
+
# pylint: disable=too-many-public-methods, too-many-instance-attributes
|
28
36
|
class DTMProvider(ABC):
|
29
37
|
"""Base class for DTM providers."""
|
30
38
|
|
@@ -44,6 +52,21 @@ class DTMProvider(ABC):
|
|
44
52
|
|
45
53
|
_instructions: str | None = None
|
46
54
|
|
55
|
+
_base_instructions = (
|
56
|
+
"ℹ️ Using **Easy mode** is recommended, as it automatically adjusts the values in the "
|
57
|
+
"image, so the terrain elevation in Giants Editor will match real world "
|
58
|
+
"elevation in meters. \n"
|
59
|
+
"ℹ️ If the terrain height difference in the real world is bigger than 255 meters, "
|
60
|
+
"the [Height scale](https://github.com/iwatkot/maps4fs/blob/main/docs/dem.md#height-scale)"
|
61
|
+
" parameter in the **map.i3d** file will be automatically adjusted. \n"
|
62
|
+
"⚡ If the **Easy mode** option is disabled, you will probably get completely flat "
|
63
|
+
"terrain, unless you adjust the DEM Multiplier Setting or the Height scale parameter in "
|
64
|
+
"the Giants Editor. \n"
|
65
|
+
"💡 You can use the **Power factor** setting to make the difference between heights "
|
66
|
+
"bigger. Be extremely careful with this setting, and use only low values, otherwise your "
|
67
|
+
"terrain may be completely broken. \n"
|
68
|
+
)
|
69
|
+
|
47
70
|
# pylint: disable=R0913, R0917
|
48
71
|
def __init__(
|
49
72
|
self,
|
@@ -169,6 +192,15 @@ class DTMProvider(ABC):
|
|
169
192
|
"""
|
170
193
|
return cls._instructions
|
171
194
|
|
195
|
+
@classmethod
|
196
|
+
def base_instructions(cls) -> str | None:
|
197
|
+
"""Instructions for using any provider.
|
198
|
+
|
199
|
+
Returns:
|
200
|
+
str: Instructions for using any provider.
|
201
|
+
"""
|
202
|
+
return cls._base_instructions
|
203
|
+
|
172
204
|
@property
|
173
205
|
def user_settings(self) -> DTMProviderSettings | None:
|
174
206
|
"""User settings of the provider.
|
@@ -212,63 +244,116 @@ class DTMProvider(ABC):
|
|
212
244
|
"""
|
213
245
|
providers = {}
|
214
246
|
for provider in cls.__subclasses__():
|
215
|
-
|
216
|
-
providers[provider._code] = provider.description() # pylint: disable=W0212
|
247
|
+
providers[provider._code] = provider.description() # pylint: disable=W0212
|
217
248
|
return providers # type: ignore
|
218
249
|
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
Arguments:
|
223
|
-
output_path (str): Path to save the downloaded tile.
|
250
|
+
@abstractmethod
|
251
|
+
def download_tiles(self) -> list[str]:
|
252
|
+
"""Download tiles from the provider.
|
224
253
|
|
225
254
|
Returns:
|
226
|
-
|
255
|
+
list: List of paths to the downloaded tiles.
|
227
256
|
"""
|
228
|
-
|
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.
|
257
|
+
raise NotImplementedError
|
239
258
|
|
240
|
-
|
241
|
-
|
259
|
+
def get_numpy(self) -> np.ndarray:
|
260
|
+
"""Get numpy array of the tile.
|
261
|
+
Resulting array must be 16 bit (signed or unsigned) integer and it should be already
|
262
|
+
windowed to the bounding box of ROI. It also must have only one channel.
|
242
263
|
|
243
264
|
Returns:
|
244
|
-
|
245
|
-
not downloaded.
|
265
|
+
np.ndarray: Numpy array of the tile.
|
246
266
|
"""
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
267
|
+
# download tiles using DTM provider implementation
|
268
|
+
tiles = self.download_tiles()
|
269
|
+
self.logger.debug(f"Downloaded {len(tiles)} DEM tiles")
|
270
|
+
|
271
|
+
# merge tiles if necessary
|
272
|
+
if len(tiles) > 1:
|
273
|
+
self.logger.debug("Multiple tiles downloaded. Merging tiles")
|
274
|
+
tile, _ = self.merge_geotiff(tiles)
|
275
|
+
else:
|
276
|
+
tile = tiles[0]
|
277
|
+
|
278
|
+
# determine CRS of the resulting tile and reproject if necessary
|
279
|
+
with rasterio.open(tile) as src:
|
280
|
+
crs = src.crs
|
281
|
+
if crs != "EPSG:4326":
|
282
|
+
self.logger.debug(f"Reprojecting GeoTIFF from {crs} to EPSG:4326...")
|
283
|
+
tile = self.reproject_geotiff(tile)
|
284
|
+
|
285
|
+
# extract region of interest from the tile
|
286
|
+
data = self.extract_roi(tile)
|
287
|
+
|
288
|
+
# process elevation data to be compatible with the game
|
289
|
+
data = self.process_elevation(data)
|
290
|
+
|
291
|
+
return data
|
292
|
+
|
293
|
+
def process_elevation(self, data: np.ndarray) -> np.ndarray:
|
294
|
+
"""Process elevation data.
|
251
295
|
|
252
|
-
|
253
|
-
|
254
|
-
Must be implemented in subclasses.
|
296
|
+
Arguments:
|
297
|
+
data (np.ndarray): Elevation data.
|
255
298
|
|
256
299
|
Returns:
|
257
|
-
|
300
|
+
np.ndarray: Processed elevation data.
|
258
301
|
"""
|
259
|
-
|
302
|
+
self.data_info = {}
|
303
|
+
self.add_numpy_params(data, "original")
|
260
304
|
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
305
|
+
data = self.ground_height_data(data)
|
306
|
+
self.add_numpy_params(data, "grounded")
|
307
|
+
|
308
|
+
original_deviation = int(self.data_info["original_deviation"])
|
309
|
+
in_game_maximum_height = 65535 // 255
|
310
|
+
if original_deviation > in_game_maximum_height:
|
311
|
+
suggested_height_scale_multiplier = (
|
312
|
+
original_deviation / in_game_maximum_height # type: ignore
|
313
|
+
)
|
314
|
+
suggested_height_scale_value = int(255 * suggested_height_scale_multiplier)
|
315
|
+
else:
|
316
|
+
suggested_height_scale_multiplier = 1
|
317
|
+
suggested_height_scale_value = 255
|
318
|
+
|
319
|
+
self.data_info["suggested_height_scale_multiplier"] = suggested_height_scale_multiplier
|
320
|
+
self.data_info["suggested_height_scale_value"] = suggested_height_scale_value
|
321
|
+
|
322
|
+
self.map.shared_settings.height_scale_multiplier = ( # type: ignore
|
323
|
+
suggested_height_scale_multiplier
|
324
|
+
)
|
325
|
+
self.map.shared_settings.height_scale_value = suggested_height_scale_value # type: ignore
|
326
|
+
|
327
|
+
if self.user_settings.easy_mode: # type: ignore
|
328
|
+
try:
|
329
|
+
data = self.normalize_dem(data)
|
330
|
+
self.add_numpy_params(data, "normalized")
|
331
|
+
|
332
|
+
normalized_deviation = self.data_info["normalized_deviation"]
|
333
|
+
z_scaling_factor = normalized_deviation / original_deviation # type: ignore
|
334
|
+
self.data_info["z_scaling_factor"] = z_scaling_factor
|
335
|
+
|
336
|
+
self.map.shared_settings.mesh_z_scaling_factor = z_scaling_factor # type: ignore
|
337
|
+
self.map.shared_settings.change_height_scale = True # type: ignore
|
338
|
+
|
339
|
+
except Exception as e: # pylint: disable=W0718
|
340
|
+
self.logger.error(
|
341
|
+
"Failed to normalize DEM data. Error: %s. Using original data.", e
|
342
|
+
)
|
343
|
+
|
344
|
+
return data.astype(np.uint16)
|
345
|
+
|
346
|
+
def info_sequence(self) -> dict[str, int | str | float] | None:
|
347
|
+
"""Returns the information sequence for the component. Must be implemented in the child
|
348
|
+
class. If the component does not have an information sequence, an empty dictionary must be
|
349
|
+
returned.
|
266
350
|
|
267
351
|
Returns:
|
268
|
-
|
352
|
+
dict[str, int | str | float] | None: Information sequence for the component.
|
269
353
|
"""
|
270
|
-
|
354
|
+
return self.data_info
|
271
355
|
|
356
|
+
# region helpers
|
272
357
|
def get_bbox(self) -> tuple[float, float, float, float]:
|
273
358
|
"""Get bounding box of the tile based on the center point and size.
|
274
359
|
|
@@ -281,6 +366,137 @@ class DTMProvider(ABC):
|
|
281
366
|
bbox = north, south, east, west
|
282
367
|
return bbox
|
283
368
|
|
369
|
+
def download_tif_files(self, urls: list[str], output_path: str) -> list[str]:
|
370
|
+
"""Download GeoTIFF files from the given URLs.
|
371
|
+
|
372
|
+
Arguments:
|
373
|
+
urls (list): List of URLs to download GeoTIFF files from.
|
374
|
+
output_path (str): Path to save the downloaded GeoTIFF files.
|
375
|
+
|
376
|
+
Returns:
|
377
|
+
list: List of paths to the downloaded GeoTIFF files.
|
378
|
+
"""
|
379
|
+
tif_files: list[str] = []
|
380
|
+
for url in urls:
|
381
|
+
file_name = os.path.basename(url)
|
382
|
+
self.logger.debug("Retrieving TIFF: %s", file_name)
|
383
|
+
file_path = os.path.join(output_path, file_name)
|
384
|
+
if not os.path.exists(file_path):
|
385
|
+
try:
|
386
|
+
# Send a GET request to the file URL
|
387
|
+
response = requests.get(url, stream=True, timeout=60)
|
388
|
+
response.raise_for_status() # Raise an error for HTTP status codes 4xx/5xx
|
389
|
+
|
390
|
+
# Write the content of the response to the file
|
391
|
+
with open(file_path, "wb") as file:
|
392
|
+
for chunk in response.iter_content(chunk_size=8192): # Download in chunks
|
393
|
+
file.write(chunk)
|
394
|
+
self.logger.info("File downloaded successfully: %s", file_path)
|
395
|
+
except requests.exceptions.RequestException as e:
|
396
|
+
self.logger.error("Failed to download file: %s", e)
|
397
|
+
else:
|
398
|
+
self.logger.debug("File already exists: %s", file_name)
|
399
|
+
if file_name.endswith('.zip'):
|
400
|
+
file_path = self.unzip_img_from_tif(file_name, output_path)
|
401
|
+
tif_files.append(file_path)
|
402
|
+
return tif_files
|
403
|
+
|
404
|
+
def unzip_img_from_tif(self, file_name: str, output_path: str) -> str:
|
405
|
+
"""Unpacks the .img file from the zip file.
|
406
|
+
|
407
|
+
Arguments:
|
408
|
+
file_name (str): Name of the file to unzip.
|
409
|
+
output_path (str): Path to the output directory.
|
410
|
+
|
411
|
+
Returns:
|
412
|
+
str: Path to the unzipped file.
|
413
|
+
"""
|
414
|
+
file_path = os.path.join(output_path, file_name)
|
415
|
+
img_file_name = file_name.replace('.zip', '.img')
|
416
|
+
img_file_path = os.path.join(output_path, img_file_name)
|
417
|
+
if not os.path.exists(img_file_path):
|
418
|
+
with ZipFile(file_path, "r") as f_in:
|
419
|
+
f_in.extract(img_file_name, output_path)
|
420
|
+
self.logger.debug("Unzipped file %s to %s", file_name, img_file_name)
|
421
|
+
else:
|
422
|
+
self.logger.debug("File already exists: %s", img_file_name)
|
423
|
+
return img_file_path
|
424
|
+
|
425
|
+
def reproject_geotiff(self, input_tiff: str) -> str:
|
426
|
+
"""Reproject a GeoTIFF file to a new coordinate reference system (CRS).
|
427
|
+
|
428
|
+
Arguments:
|
429
|
+
input_tiff (str): Path to the input GeoTIFF file.
|
430
|
+
|
431
|
+
Returns:
|
432
|
+
str: Path to the reprojected GeoTIFF file.
|
433
|
+
"""
|
434
|
+
output_tiff = os.path.join(self._tile_directory, "reprojected.tif")
|
435
|
+
|
436
|
+
# Open the source GeoTIFF
|
437
|
+
self.logger.debug("Reprojecting GeoTIFF to EPSG:4326 CRS...")
|
438
|
+
with rasterio.open(input_tiff) as src:
|
439
|
+
# Get the transform, width, and height of the target CRS
|
440
|
+
transform, width, height = calculate_default_transform(
|
441
|
+
src.crs, "EPSG:4326", src.width, src.height, *src.bounds
|
442
|
+
)
|
443
|
+
|
444
|
+
# Update the metadata for the target GeoTIFF
|
445
|
+
kwargs = src.meta.copy()
|
446
|
+
kwargs.update(
|
447
|
+
{"crs": "EPSG:4326", "transform": transform, "width": width, "height": height}
|
448
|
+
)
|
449
|
+
|
450
|
+
# Open the destination GeoTIFF file and reproject
|
451
|
+
with rasterio.open(output_tiff, "w", **kwargs) as dst:
|
452
|
+
for i in range(1, src.count + 1): # Iterate over all raster bands
|
453
|
+
reproject(
|
454
|
+
source=rasterio.band(src, i),
|
455
|
+
destination=rasterio.band(dst, i),
|
456
|
+
src_transform=src.transform,
|
457
|
+
src_crs=src.crs,
|
458
|
+
dst_transform=transform,
|
459
|
+
dst_crs="EPSG:4326",
|
460
|
+
resampling=Resampling.nearest, # Choose resampling method
|
461
|
+
)
|
462
|
+
|
463
|
+
self.logger.debug("Reprojected GeoTIFF saved to %s", output_tiff)
|
464
|
+
return output_tiff
|
465
|
+
|
466
|
+
def merge_geotiff(self, input_files: list[str]) -> tuple[str, str]:
|
467
|
+
"""Merge multiple GeoTIFF files into a single GeoTIFF file.
|
468
|
+
|
469
|
+
Arguments:
|
470
|
+
input_files (list): List of input GeoTIFF files to merge.
|
471
|
+
"""
|
472
|
+
output_file = os.path.join(self._tile_directory, "merged.tif")
|
473
|
+
# Open all input GeoTIFF files as datasets
|
474
|
+
self.logger.debug("Merging tiff files...")
|
475
|
+
datasets = [rasterio.open(file) for file in input_files]
|
476
|
+
|
477
|
+
# Merge datasets
|
478
|
+
crs = datasets[0].crs
|
479
|
+
mosaic, out_transform = merge(datasets, nodata=0)
|
480
|
+
|
481
|
+
# Get metadata from the first file and update it for the output
|
482
|
+
out_meta = datasets[0].meta.copy()
|
483
|
+
out_meta.update(
|
484
|
+
{
|
485
|
+
"driver": "GTiff",
|
486
|
+
"height": mosaic.shape[1],
|
487
|
+
"width": mosaic.shape[2],
|
488
|
+
"transform": out_transform,
|
489
|
+
"count": mosaic.shape[0], # Number of bands
|
490
|
+
}
|
491
|
+
)
|
492
|
+
|
493
|
+
# Write merged GeoTIFF to the output file
|
494
|
+
with rasterio.open(output_file, "w", **out_meta) as dest:
|
495
|
+
dest.write(mosaic)
|
496
|
+
|
497
|
+
self.logger.debug("GeoTIFF images merged successfully into %s", output_file)
|
498
|
+
return output_file, crs
|
499
|
+
|
284
500
|
def extract_roi(self, tile_path: str) -> np.ndarray:
|
285
501
|
"""Extract region of interest (ROI) from the GeoTIFF file.
|
286
502
|
|
@@ -304,18 +520,90 @@ class DTMProvider(ABC):
|
|
304
520
|
window.width,
|
305
521
|
window.height,
|
306
522
|
)
|
307
|
-
data = src.read(1, window=window)
|
523
|
+
data = src.read(1, window=window, masked=True)
|
308
524
|
if not data.size > 0:
|
309
525
|
raise ValueError("No data in the tile.")
|
310
526
|
|
311
527
|
return data
|
312
528
|
|
313
|
-
def
|
314
|
-
"""
|
315
|
-
|
316
|
-
|
529
|
+
def normalize_dem(self, data: np.ndarray) -> np.ndarray:
|
530
|
+
"""Normalize DEM data to 16-bit unsigned integer using max height from settings.
|
531
|
+
|
532
|
+
Arguments:
|
533
|
+
data (np.ndarray): DEM data from SRTM file after cropping.
|
317
534
|
|
318
535
|
Returns:
|
319
|
-
|
536
|
+
np.ndarray: Normalized DEM data.
|
320
537
|
"""
|
321
|
-
|
538
|
+
maximum_height = int(data.max())
|
539
|
+
minimum_height = int(data.min())
|
540
|
+
deviation = maximum_height - minimum_height
|
541
|
+
self.logger.debug(
|
542
|
+
"Maximum height: %s. Minimum height: %s. Deviation: %s.",
|
543
|
+
maximum_height,
|
544
|
+
minimum_height,
|
545
|
+
deviation,
|
546
|
+
)
|
547
|
+
self.logger.debug("Number of unique values in original DEM data: %s.", np.unique(data).size)
|
548
|
+
|
549
|
+
adjusted_maximum_height = maximum_height * 255
|
550
|
+
adjusted_maximum_height = min(adjusted_maximum_height, 65535)
|
551
|
+
scaling_factor = adjusted_maximum_height / maximum_height
|
552
|
+
self.logger.debug(
|
553
|
+
"Adjusted maximum height: %s. Scaling factor: %s.",
|
554
|
+
adjusted_maximum_height,
|
555
|
+
scaling_factor,
|
556
|
+
)
|
557
|
+
|
558
|
+
if self.user_settings.power_factor: # type: ignore
|
559
|
+
power_factor = 1 + self.user_settings.power_factor / 10 # type: ignore
|
560
|
+
self.logger.debug(
|
561
|
+
"Applying power factor: %s to the DEM data.",
|
562
|
+
power_factor,
|
563
|
+
)
|
564
|
+
data = np.power(data, power_factor).astype(np.uint16)
|
565
|
+
|
566
|
+
normalized_data = np.round(data * scaling_factor).astype(np.uint16)
|
567
|
+
self.logger.debug(
|
568
|
+
"Normalized data maximum height: %s. Minimum height: %s. Number of unique values: %s.",
|
569
|
+
normalized_data.max(),
|
570
|
+
normalized_data.min(),
|
571
|
+
np.unique(normalized_data).size,
|
572
|
+
)
|
573
|
+
return normalized_data
|
574
|
+
|
575
|
+
@staticmethod
|
576
|
+
def ground_height_data(data: np.ndarray, add_one: bool = True) -> np.ndarray:
|
577
|
+
"""Shift the data to ground level (0 meter).
|
578
|
+
Optionally add one meter to the data to leave some room
|
579
|
+
for the water level and pit modifications.
|
580
|
+
|
581
|
+
Arguments:
|
582
|
+
data (np.ndarray): DEM data after cropping.
|
583
|
+
add_one (bool): Add one meter to the data
|
584
|
+
|
585
|
+
Returns:
|
586
|
+
np.ndarray: Unsigned DEM data.
|
587
|
+
"""
|
588
|
+
data = data - data.min()
|
589
|
+
if add_one:
|
590
|
+
data = data + 1
|
591
|
+
return data
|
592
|
+
|
593
|
+
def add_numpy_params(
|
594
|
+
self,
|
595
|
+
data: np.ndarray,
|
596
|
+
prefix: str,
|
597
|
+
) -> None:
|
598
|
+
"""Add numpy array parameters to the data_info dictionary.
|
599
|
+
|
600
|
+
Arguments:
|
601
|
+
data (np.ndarray): Numpy array of the tile.
|
602
|
+
prefix (str): Prefix for the parameters.
|
603
|
+
"""
|
604
|
+
self.data_info[f"{prefix}_minimum_height"] = int(data.min()) # type: ignore
|
605
|
+
self.data_info[f"{prefix}_maximum_height"] = int(data.max()) # type: ignore
|
606
|
+
self.data_info[f"{prefix}_deviation"] = int(data.max() - data.min()) # type: ignore
|
607
|
+
self.data_info[f"{prefix}_unique_values"] = int(np.unique(data).size) # type: ignore
|
608
|
+
|
609
|
+
# endregion
|