maps4fs 1.7.2__tar.gz → 1.7.3__tar.gz
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-1.7.2 → maps4fs-1.7.3}/PKG-INFO +1 -2
- {maps4fs-1.7.2 → maps4fs-1.7.3}/README.md +0 -1
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/__init__.py +1 -0
- maps4fs-1.7.3/maps4fs/generator/dtm/dtm.py +549 -0
- maps4fs-1.7.3/maps4fs/generator/dtm/nrw.py +127 -0
- maps4fs-1.7.3/maps4fs/generator/dtm/srtm.py +124 -0
- maps4fs-1.7.3/maps4fs/generator/dtm/usgs.py +135 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs.egg-info/PKG-INFO +1 -2
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs.egg-info/SOURCES.txt +1 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/pyproject.toml +1 -1
- maps4fs-1.7.2/maps4fs/generator/dtm/dtm.py +0 -321
- maps4fs-1.7.2/maps4fs/generator/dtm/srtm.py +0 -226
- maps4fs-1.7.2/maps4fs/generator/dtm/usgs.py +0 -351
- {maps4fs-1.7.2 → maps4fs-1.7.3}/LICENSE.md +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/generator/__init__.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/generator/background.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/generator/component.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/generator/config.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/generator/dem.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/generator/dtm/__init__.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/generator/game.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/generator/grle.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/generator/i3d.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/generator/map.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/generator/qgis.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/generator/satellite.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/generator/settings.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/generator/texture.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/logger.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/toolbox/__init__.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/toolbox/background.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs/toolbox/dem.py +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs.egg-info/dependency_links.txt +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs.egg-info/requires.txt +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/maps4fs.egg-info/top_level.txt +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/setup.cfg +0 -0
- {maps4fs-1.7.2 → maps4fs-1.7.3}/tests/test_generator.py +0 -0
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.2
|
2
2
|
Name: maps4fs
|
3
|
-
Version: 1.7.
|
3
|
+
Version: 1.7.3
|
4
4
|
Summary: Generate map templates for Farming Simulator from real places.
|
5
5
|
Author-email: iwatkot <iwatkot@gmail.com>
|
6
6
|
License: MIT License
|
@@ -36,7 +36,6 @@ Requires-Dist: pydantic
|
|
36
36
|
<a href="#How-To-Run">How-To-Run</a><br>
|
37
37
|
<a href="docs/FAQ.md">FAQ</a> •
|
38
38
|
<a href="docs/map_structure.md">Map Structure</a> •
|
39
|
-
<a href="docs/tips_and_hints.md">Tips and Hints</a> •
|
40
39
|
<a href="#Modder-Toolbox">Modder Toolbox</a><br>
|
41
40
|
<a href="#Supported-objects">Supported objects</a> •
|
42
41
|
<a href="#Generation-info">Generation info</a> •
|
@@ -10,7 +10,6 @@
|
|
10
10
|
<a href="#How-To-Run">How-To-Run</a><br>
|
11
11
|
<a href="docs/FAQ.md">FAQ</a> •
|
12
12
|
<a href="docs/map_structure.md">Map Structure</a> •
|
13
|
-
<a href="docs/tips_and_hints.md">Tips and Hints</a> •
|
14
13
|
<a href="#Modder-Toolbox">Modder Toolbox</a><br>
|
15
14
|
<a href="#Supported-objects">Supported objects</a> •
|
16
15
|
<a href="#Generation-info">Generation info</a> •
|
@@ -2,6 +2,7 @@
|
|
2
2
|
from maps4fs.generator.dtm.dtm import DTMProvider
|
3
3
|
from maps4fs.generator.dtm.srtm import SRTM30Provider, SRTM30ProviderSettings
|
4
4
|
from maps4fs.generator.dtm.usgs import USGSProvider, USGSProviderSettings
|
5
|
+
from maps4fs.generator.dtm.nrw import NRWProvider, NRWProviderSettings
|
5
6
|
from maps4fs.generator.game import Game
|
6
7
|
from maps4fs.generator.map import Map
|
7
8
|
from maps4fs.generator.settings import (
|
@@ -0,0 +1,549 @@
|
|
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
|
+
from rasterio.warp import calculate_default_transform, reproject
|
15
|
+
from rasterio.enums import Resampling
|
16
|
+
from rasterio.merge import merge
|
17
|
+
|
18
|
+
from pydantic import BaseModel
|
19
|
+
|
20
|
+
from maps4fs.logger import Logger
|
21
|
+
|
22
|
+
if TYPE_CHECKING:
|
23
|
+
from maps4fs.generator.map import Map
|
24
|
+
|
25
|
+
|
26
|
+
class DTMProviderSettings(BaseModel):
|
27
|
+
"""Base class for DTM provider settings models."""
|
28
|
+
|
29
|
+
easy_mode: bool = True
|
30
|
+
power_factor: int = 0
|
31
|
+
|
32
|
+
|
33
|
+
# pylint: disable=too-many-public-methods, too-many-instance-attributes
|
34
|
+
class DTMProvider(ABC):
|
35
|
+
"""Base class for DTM providers."""
|
36
|
+
|
37
|
+
_code: str | None = None
|
38
|
+
_name: str | None = None
|
39
|
+
_region: str | None = None
|
40
|
+
_icon: str | None = None
|
41
|
+
_resolution: float | str | None = None
|
42
|
+
|
43
|
+
_url: str | None = None
|
44
|
+
|
45
|
+
_author: str | None = None
|
46
|
+
_contributors: str | None = None
|
47
|
+
_is_community: bool = False
|
48
|
+
_is_base: bool = False
|
49
|
+
_settings: Type[DTMProviderSettings] | None = None
|
50
|
+
|
51
|
+
_instructions: str | None = None
|
52
|
+
|
53
|
+
_base_instructions = (
|
54
|
+
"ℹ️ Using **Easy mode** is recommended, as it automatically adjusts the values in the "
|
55
|
+
"image, so the terrain elevation in Giants Editor will match real world "
|
56
|
+
"elevation in meters. \n"
|
57
|
+
"ℹ️ If the terrain height difference in the real world is bigger than 255 meters, "
|
58
|
+
"the [Height scale](https://github.com/iwatkot/maps4fs/blob/main/docs/dem.md#height-scale)"
|
59
|
+
" parameter in the **map.i3d** file will be automatically adjusted. \n"
|
60
|
+
"⚡ If the **Easy mode** option is disabled, you will probably get completely flat "
|
61
|
+
"terrain, unless you adjust the DEM Multiplier Setting or the Height scale parameter in "
|
62
|
+
"the Giants Editor. \n"
|
63
|
+
"💡 You can use the **Power factor** setting to make the difference between heights "
|
64
|
+
"bigger. Be extremely careful with this setting, and use only low values, otherwise your "
|
65
|
+
"terrain may be completely broken. \n"
|
66
|
+
)
|
67
|
+
|
68
|
+
# pylint: disable=R0913, R0917
|
69
|
+
def __init__(
|
70
|
+
self,
|
71
|
+
coordinates: tuple[float, float],
|
72
|
+
user_settings: DTMProviderSettings | None,
|
73
|
+
size: int,
|
74
|
+
directory: str,
|
75
|
+
logger: Logger,
|
76
|
+
map: Map | None = None, # pylint: disable=W0622
|
77
|
+
):
|
78
|
+
self._coordinates = coordinates
|
79
|
+
self._user_settings = user_settings
|
80
|
+
self._size = size
|
81
|
+
|
82
|
+
if not self._code:
|
83
|
+
raise ValueError("Provider code must be defined.")
|
84
|
+
self._tile_directory = os.path.join(directory, self._code)
|
85
|
+
os.makedirs(self._tile_directory, exist_ok=True)
|
86
|
+
|
87
|
+
self.logger = logger
|
88
|
+
self.map = map
|
89
|
+
|
90
|
+
self._data_info: dict[str, int | str | float] | None = None
|
91
|
+
|
92
|
+
@property
|
93
|
+
def data_info(self) -> dict[str, int | str | float] | None:
|
94
|
+
"""Information about the DTM data.
|
95
|
+
|
96
|
+
Returns:
|
97
|
+
dict: Information about the DTM data.
|
98
|
+
"""
|
99
|
+
return self._data_info
|
100
|
+
|
101
|
+
@data_info.setter
|
102
|
+
def data_info(self, value: dict[str, int | str | float] | None) -> None:
|
103
|
+
"""Set information about the DTM data.
|
104
|
+
|
105
|
+
Arguments:
|
106
|
+
value (dict): Information about the DTM data.
|
107
|
+
"""
|
108
|
+
self._data_info = value
|
109
|
+
|
110
|
+
@property
|
111
|
+
def coordinates(self) -> tuple[float, float]:
|
112
|
+
"""Coordinates of the center point of the DTM data.
|
113
|
+
|
114
|
+
Returns:
|
115
|
+
tuple: Latitude and longitude of the center point.
|
116
|
+
"""
|
117
|
+
return self._coordinates
|
118
|
+
|
119
|
+
@property
|
120
|
+
def size(self) -> int:
|
121
|
+
"""Size of the DTM data in meters.
|
122
|
+
|
123
|
+
Returns:
|
124
|
+
int: Size of the DTM data.
|
125
|
+
"""
|
126
|
+
return self._size
|
127
|
+
|
128
|
+
@property
|
129
|
+
def url(self) -> str | None:
|
130
|
+
"""URL of the provider."""
|
131
|
+
return self._url
|
132
|
+
|
133
|
+
def formatted_url(self, **kwargs) -> str:
|
134
|
+
"""Formatted URL of the provider."""
|
135
|
+
if not self.url:
|
136
|
+
raise ValueError("URL must be defined.")
|
137
|
+
return self.url.format(**kwargs)
|
138
|
+
|
139
|
+
@classmethod
|
140
|
+
def author(cls) -> str | None:
|
141
|
+
"""Author of the provider.
|
142
|
+
|
143
|
+
Returns:
|
144
|
+
str: Author of the provider.
|
145
|
+
"""
|
146
|
+
return cls._author
|
147
|
+
|
148
|
+
@classmethod
|
149
|
+
def contributors(cls) -> str | None:
|
150
|
+
"""Contributors of the provider.
|
151
|
+
|
152
|
+
Returns:
|
153
|
+
str: Contributors of the provider.
|
154
|
+
"""
|
155
|
+
return cls._contributors
|
156
|
+
|
157
|
+
@classmethod
|
158
|
+
def is_base(cls) -> bool:
|
159
|
+
"""Is the provider a base provider.
|
160
|
+
|
161
|
+
Returns:
|
162
|
+
bool: True if the provider is a base provider, False otherwise.
|
163
|
+
"""
|
164
|
+
return cls._is_base
|
165
|
+
|
166
|
+
@classmethod
|
167
|
+
def is_community(cls) -> bool:
|
168
|
+
"""Is the provider a community-driven project.
|
169
|
+
|
170
|
+
Returns:
|
171
|
+
bool: True if the provider is a community-driven project, False otherwise.
|
172
|
+
"""
|
173
|
+
return cls._is_community
|
174
|
+
|
175
|
+
@classmethod
|
176
|
+
def settings(cls) -> Type[DTMProviderSettings] | None:
|
177
|
+
"""Settings model of the provider.
|
178
|
+
|
179
|
+
Returns:
|
180
|
+
Type[DTMProviderSettings]: Settings model of the provider.
|
181
|
+
"""
|
182
|
+
return cls._settings
|
183
|
+
|
184
|
+
@classmethod
|
185
|
+
def instructions(cls) -> str | None:
|
186
|
+
"""Instructions for using the provider.
|
187
|
+
|
188
|
+
Returns:
|
189
|
+
str: Instructions for using the provider.
|
190
|
+
"""
|
191
|
+
return cls._instructions
|
192
|
+
|
193
|
+
@classmethod
|
194
|
+
def base_instructions(cls) -> str | None:
|
195
|
+
"""Instructions for using any provider.
|
196
|
+
|
197
|
+
Returns:
|
198
|
+
str: Instructions for using any provider.
|
199
|
+
"""
|
200
|
+
return cls._base_instructions
|
201
|
+
|
202
|
+
@property
|
203
|
+
def user_settings(self) -> DTMProviderSettings | None:
|
204
|
+
"""User settings of the provider.
|
205
|
+
|
206
|
+
Returns:
|
207
|
+
DTMProviderSettings: User settings of the provider.
|
208
|
+
"""
|
209
|
+
return self._user_settings
|
210
|
+
|
211
|
+
@classmethod
|
212
|
+
def description(cls) -> str:
|
213
|
+
"""Description of the provider.
|
214
|
+
|
215
|
+
Returns:
|
216
|
+
str: Provider description.
|
217
|
+
"""
|
218
|
+
return f"{cls._icon} {cls._region} [{cls._resolution} m/px] {cls._name}"
|
219
|
+
|
220
|
+
@classmethod
|
221
|
+
def get_provider_by_code(cls, code: str) -> Type[DTMProvider] | None:
|
222
|
+
"""Get a provider by its code.
|
223
|
+
|
224
|
+
Arguments:
|
225
|
+
code (str): Provider code.
|
226
|
+
|
227
|
+
Returns:
|
228
|
+
DTMProvider: Provider class or None if not found.
|
229
|
+
"""
|
230
|
+
for provider in cls.__subclasses__():
|
231
|
+
if provider._code == code: # pylint: disable=W0212
|
232
|
+
return provider
|
233
|
+
return None
|
234
|
+
|
235
|
+
@classmethod
|
236
|
+
def get_provider_descriptions(cls) -> dict[str, str]:
|
237
|
+
"""Get descriptions of all providers, where keys are provider codes and
|
238
|
+
values are provider descriptions.
|
239
|
+
|
240
|
+
Returns:
|
241
|
+
dict: Provider descriptions.
|
242
|
+
"""
|
243
|
+
providers = {}
|
244
|
+
for provider in cls.__subclasses__():
|
245
|
+
providers[provider._code] = provider.description() # pylint: disable=W0212
|
246
|
+
return providers # type: ignore
|
247
|
+
|
248
|
+
@abstractmethod
|
249
|
+
def download_tiles(self) -> list[str]:
|
250
|
+
"""Download tiles from the provider.
|
251
|
+
|
252
|
+
Returns:
|
253
|
+
list: List of paths to the downloaded tiles.
|
254
|
+
"""
|
255
|
+
raise NotImplementedError
|
256
|
+
|
257
|
+
def get_numpy(self) -> np.ndarray:
|
258
|
+
"""Get numpy array of the tile.
|
259
|
+
Resulting array must be 16 bit (signed or unsigned) integer and it should be already
|
260
|
+
windowed to the bounding box of ROI. It also must have only one channel.
|
261
|
+
|
262
|
+
Returns:
|
263
|
+
np.ndarray: Numpy array of the tile.
|
264
|
+
"""
|
265
|
+
# download tiles using DTM provider implementation
|
266
|
+
tiles = self.download_tiles()
|
267
|
+
self.logger.debug(f"Downloaded {len(tiles)} DEM tiles")
|
268
|
+
|
269
|
+
# merge tiles if necessary
|
270
|
+
if len(tiles) > 1:
|
271
|
+
self.logger.debug("Multiple tiles downloaded. Merging tiles")
|
272
|
+
tile, _ = self.merge_geotiff(tiles)
|
273
|
+
else:
|
274
|
+
tile = tiles[0]
|
275
|
+
|
276
|
+
# determine CRS of the resulting tile and reproject if necessary
|
277
|
+
with rasterio.open(tile) as src:
|
278
|
+
crs = src.crs
|
279
|
+
if crs != "EPSG:4326":
|
280
|
+
self.logger.debug(f"Reprojecting GeoTIFF from {crs} to EPSG:4326...")
|
281
|
+
tile = self.reproject_geotiff(tile)
|
282
|
+
|
283
|
+
# extract region of interest from the tile
|
284
|
+
data = self.extract_roi(tile)
|
285
|
+
|
286
|
+
# process elevation data to be compatible with the game
|
287
|
+
data = self.process_elevation(data)
|
288
|
+
|
289
|
+
return data
|
290
|
+
|
291
|
+
def process_elevation(self, data: np.ndarray) -> np.ndarray:
|
292
|
+
"""Process elevation data.
|
293
|
+
|
294
|
+
Arguments:
|
295
|
+
data (np.ndarray): Elevation data.
|
296
|
+
|
297
|
+
Returns:
|
298
|
+
np.ndarray: Processed elevation data.
|
299
|
+
"""
|
300
|
+
self.data_info = {}
|
301
|
+
self.add_numpy_params(data, "original")
|
302
|
+
|
303
|
+
data = self.signed_to_unsigned(data)
|
304
|
+
self.add_numpy_params(data, "grounded")
|
305
|
+
|
306
|
+
original_deviation = int(self.data_info["original_deviation"])
|
307
|
+
in_game_maximum_height = 65535 // 255
|
308
|
+
if original_deviation > in_game_maximum_height:
|
309
|
+
suggested_height_scale_multiplier = (
|
310
|
+
original_deviation / in_game_maximum_height # type: ignore
|
311
|
+
)
|
312
|
+
suggested_height_scale_value = int(255 * suggested_height_scale_multiplier)
|
313
|
+
else:
|
314
|
+
suggested_height_scale_multiplier = 1
|
315
|
+
suggested_height_scale_value = 255
|
316
|
+
|
317
|
+
self.data_info["suggested_height_scale_multiplier"] = suggested_height_scale_multiplier
|
318
|
+
self.data_info["suggested_height_scale_value"] = suggested_height_scale_value
|
319
|
+
|
320
|
+
self.map.shared_settings.height_scale_multiplier = ( # type: ignore
|
321
|
+
suggested_height_scale_multiplier
|
322
|
+
)
|
323
|
+
self.map.shared_settings.height_scale_value = suggested_height_scale_value # type: ignore
|
324
|
+
|
325
|
+
if self.user_settings.easy_mode: # type: ignore
|
326
|
+
try:
|
327
|
+
data = self.normalize_dem(data)
|
328
|
+
self.add_numpy_params(data, "normalized")
|
329
|
+
|
330
|
+
normalized_deviation = self.data_info["normalized_deviation"]
|
331
|
+
z_scaling_factor = normalized_deviation / original_deviation # type: ignore
|
332
|
+
self.data_info["z_scaling_factor"] = z_scaling_factor
|
333
|
+
|
334
|
+
self.map.shared_settings.mesh_z_scaling_factor = z_scaling_factor # type: ignore
|
335
|
+
self.map.shared_settings.change_height_scale = True # type: ignore
|
336
|
+
|
337
|
+
except Exception as e: # pylint: disable=W0718
|
338
|
+
self.logger.error(
|
339
|
+
"Failed to normalize DEM data. Error: %s. Using original data.", e
|
340
|
+
)
|
341
|
+
|
342
|
+
return data
|
343
|
+
|
344
|
+
def info_sequence(self) -> dict[str, int | str | float] | None:
|
345
|
+
"""Returns the information sequence for the component. Must be implemented in the child
|
346
|
+
class. If the component does not have an information sequence, an empty dictionary must be
|
347
|
+
returned.
|
348
|
+
|
349
|
+
Returns:
|
350
|
+
dict[str, int | str | float] | None: Information sequence for the component.
|
351
|
+
"""
|
352
|
+
return self.data_info
|
353
|
+
|
354
|
+
# region helpers
|
355
|
+
def get_bbox(self) -> tuple[float, float, float, float]:
|
356
|
+
"""Get bounding box of the tile based on the center point and size.
|
357
|
+
|
358
|
+
Returns:
|
359
|
+
tuple: Bounding box of the tile (north, south, east, west).
|
360
|
+
"""
|
361
|
+
west, south, east, north = ox.utils_geo.bbox_from_point( # type: ignore
|
362
|
+
self.coordinates, dist=self.size // 2, project_utm=False
|
363
|
+
)
|
364
|
+
bbox = north, south, east, west
|
365
|
+
return bbox
|
366
|
+
|
367
|
+
def reproject_geotiff(self, input_tiff: str) -> str:
|
368
|
+
"""Reproject a GeoTIFF file to a new coordinate reference system (CRS).
|
369
|
+
|
370
|
+
Arguments:
|
371
|
+
input_tiff (str): Path to the input GeoTIFF file.
|
372
|
+
target_crs (str): Target CRS (e.g., EPSG:4326 for CRS:84).
|
373
|
+
|
374
|
+
Returns:
|
375
|
+
str: Path to the reprojected GeoTIFF file.
|
376
|
+
"""
|
377
|
+
output_tiff = os.path.join(self._tile_directory, "merged.tif")
|
378
|
+
|
379
|
+
# Open the source GeoTIFF
|
380
|
+
self.logger.debug("Reprojecting GeoTIFF to %s CRS...", "EPSG:4326")
|
381
|
+
with rasterio.open(input_tiff) as src:
|
382
|
+
# Get the transform, width, and height of the target CRS
|
383
|
+
transform, width, height = calculate_default_transform(
|
384
|
+
src.crs, "EPSG:4326", src.width, src.height, *src.bounds
|
385
|
+
)
|
386
|
+
|
387
|
+
# Update the metadata for the target GeoTIFF
|
388
|
+
kwargs = src.meta.copy()
|
389
|
+
kwargs.update(
|
390
|
+
{"crs": "EPSG:4326", "transform": transform, "width": width, "height": height}
|
391
|
+
)
|
392
|
+
|
393
|
+
# Open the destination GeoTIFF file and reproject
|
394
|
+
with rasterio.open(output_tiff, "w", **kwargs) as dst:
|
395
|
+
for i in range(1, src.count + 1): # Iterate over all raster bands
|
396
|
+
reproject(
|
397
|
+
source=rasterio.band(src, i),
|
398
|
+
destination=rasterio.band(dst, i),
|
399
|
+
src_transform=src.transform,
|
400
|
+
src_crs=src.crs,
|
401
|
+
dst_transform=transform,
|
402
|
+
dst_crs="EPSG:4326",
|
403
|
+
resampling=Resampling.nearest, # Choose resampling method
|
404
|
+
)
|
405
|
+
|
406
|
+
self.logger.debug("Reprojected GeoTIFF saved to %s", output_tiff)
|
407
|
+
return output_tiff
|
408
|
+
|
409
|
+
def merge_geotiff(self, input_files: list[str]) -> tuple[str, str]:
|
410
|
+
"""Merge multiple GeoTIFF files into a single GeoTIFF file.
|
411
|
+
|
412
|
+
Arguments:
|
413
|
+
input_files (list): List of input GeoTIFF files to merge.
|
414
|
+
output_file (str): Path to save the merged GeoTIFF file.
|
415
|
+
"""
|
416
|
+
output_file = os.path.join(self._tile_directory, "merged.tif")
|
417
|
+
# Open all input GeoTIFF files as datasets
|
418
|
+
self.logger.debug("Merging tiff files...")
|
419
|
+
datasets = [rasterio.open(file) for file in input_files]
|
420
|
+
|
421
|
+
# Merge datasets
|
422
|
+
crs = datasets[0].crs
|
423
|
+
mosaic, out_transform = merge(datasets, nodata=0)
|
424
|
+
|
425
|
+
# Get metadata from the first file and update it for the output
|
426
|
+
out_meta = datasets[0].meta.copy()
|
427
|
+
out_meta.update(
|
428
|
+
{
|
429
|
+
"driver": "GTiff",
|
430
|
+
"height": mosaic.shape[1],
|
431
|
+
"width": mosaic.shape[2],
|
432
|
+
"transform": out_transform,
|
433
|
+
"count": mosaic.shape[0], # Number of bands
|
434
|
+
}
|
435
|
+
)
|
436
|
+
|
437
|
+
# Write merged GeoTIFF to the output file
|
438
|
+
with rasterio.open(output_file, "w", **out_meta) as dest:
|
439
|
+
dest.write(mosaic)
|
440
|
+
|
441
|
+
self.logger.debug("GeoTIFF images merged successfully into %s", output_file)
|
442
|
+
return output_file, crs
|
443
|
+
|
444
|
+
def extract_roi(self, tile_path: str) -> np.ndarray:
|
445
|
+
"""Extract region of interest (ROI) from the GeoTIFF file.
|
446
|
+
|
447
|
+
Arguments:
|
448
|
+
tile_path (str): Path to the GeoTIFF file.
|
449
|
+
|
450
|
+
Raises:
|
451
|
+
ValueError: If the tile does not contain any data.
|
452
|
+
|
453
|
+
Returns:
|
454
|
+
np.ndarray: Numpy array of the ROI.
|
455
|
+
"""
|
456
|
+
north, south, east, west = self.get_bbox()
|
457
|
+
with rasterio.open(tile_path) as src:
|
458
|
+
self.logger.debug("Opened tile, shape: %s, dtype: %s.", src.shape, src.dtypes[0])
|
459
|
+
window = rasterio.windows.from_bounds(west, south, east, north, src.transform)
|
460
|
+
self.logger.debug(
|
461
|
+
"Window parameters. Column offset: %s, row offset: %s, width: %s, height: %s.",
|
462
|
+
window.col_off,
|
463
|
+
window.row_off,
|
464
|
+
window.width,
|
465
|
+
window.height,
|
466
|
+
)
|
467
|
+
data = src.read(1, window=window, masked=True)
|
468
|
+
if not data.size > 0:
|
469
|
+
raise ValueError("No data in the tile.")
|
470
|
+
|
471
|
+
return data
|
472
|
+
|
473
|
+
def normalize_dem(self, data: np.ndarray) -> np.ndarray:
|
474
|
+
"""Normalize DEM data to 16-bit unsigned integer using max height from settings.
|
475
|
+
|
476
|
+
Arguments:
|
477
|
+
data (np.ndarray): DEM data from SRTM file after cropping.
|
478
|
+
|
479
|
+
Returns:
|
480
|
+
np.ndarray: Normalized DEM data.
|
481
|
+
"""
|
482
|
+
maximum_height = int(data.max())
|
483
|
+
minimum_height = int(data.min())
|
484
|
+
deviation = maximum_height - minimum_height
|
485
|
+
self.logger.debug(
|
486
|
+
"Maximum height: %s. Minimum height: %s. Deviation: %s.",
|
487
|
+
maximum_height,
|
488
|
+
minimum_height,
|
489
|
+
deviation,
|
490
|
+
)
|
491
|
+
self.logger.debug("Number of unique values in original DEM data: %s.", np.unique(data).size)
|
492
|
+
|
493
|
+
adjusted_maximum_height = maximum_height * 255
|
494
|
+
adjusted_maximum_height = min(adjusted_maximum_height, 65535)
|
495
|
+
scaling_factor = adjusted_maximum_height / maximum_height
|
496
|
+
self.logger.debug(
|
497
|
+
"Adjusted maximum height: %s. Scaling factor: %s.",
|
498
|
+
adjusted_maximum_height,
|
499
|
+
scaling_factor,
|
500
|
+
)
|
501
|
+
|
502
|
+
if self.user_settings.power_factor: # type: ignore
|
503
|
+
power_factor = 1 + self.user_settings.power_factor / 10 # type: ignore
|
504
|
+
self.logger.debug(
|
505
|
+
"Applying power factor: %s to the DEM data.",
|
506
|
+
power_factor,
|
507
|
+
)
|
508
|
+
data = np.power(data, power_factor).astype(np.uint16)
|
509
|
+
|
510
|
+
normalized_data = np.round(data * scaling_factor).astype(np.uint16)
|
511
|
+
self.logger.debug(
|
512
|
+
"Normalized data maximum height: %s. Minimum height: %s. Number of unique values: %s.",
|
513
|
+
normalized_data.max(),
|
514
|
+
normalized_data.min(),
|
515
|
+
np.unique(normalized_data).size,
|
516
|
+
)
|
517
|
+
return normalized_data
|
518
|
+
|
519
|
+
def signed_to_unsigned(self, data: np.ndarray, add_one: bool = True) -> np.ndarray:
|
520
|
+
"""Convert signed 16-bit integer to unsigned 16-bit integer.
|
521
|
+
|
522
|
+
Arguments:
|
523
|
+
data (np.ndarray): DEM data from SRTM file after cropping.
|
524
|
+
|
525
|
+
Returns:
|
526
|
+
np.ndarray: Unsigned DEM data.
|
527
|
+
"""
|
528
|
+
data = data - data.min()
|
529
|
+
if add_one:
|
530
|
+
data = data + 1
|
531
|
+
return data.astype(np.uint16)
|
532
|
+
|
533
|
+
def add_numpy_params(
|
534
|
+
self,
|
535
|
+
data: np.ndarray,
|
536
|
+
prefix: str,
|
537
|
+
) -> None:
|
538
|
+
"""Add numpy array parameters to the data_info dictionary.
|
539
|
+
|
540
|
+
Arguments:
|
541
|
+
data (np.ndarray): Numpy array of the tile.
|
542
|
+
prefix (str): Prefix for the parameters.
|
543
|
+
"""
|
544
|
+
self.data_info[f"{prefix}_minimum_height"] = int(data.min()) # type: ignore
|
545
|
+
self.data_info[f"{prefix}_maximum_height"] = int(data.max()) # type: ignore
|
546
|
+
self.data_info[f"{prefix}_deviation"] = int(data.max() - data.min()) # type: ignore
|
547
|
+
self.data_info[f"{prefix}_unique_values"] = int(np.unique(data).size) # type: ignore
|
548
|
+
|
549
|
+
# endregion
|