maps4fs 1.7.1__py3-none-any.whl → 1.7.3__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/__init__.py +4 -2
- maps4fs/generator/dtm/dtm.py +276 -48
- maps4fs/generator/dtm/nrw.py +127 -0
- maps4fs/generator/dtm/srtm.py +59 -161
- maps4fs/generator/dtm/usgs.py +6 -222
- maps4fs/generator/texture.py +5 -1
- {maps4fs-1.7.1.dist-info → maps4fs-1.7.3.dist-info}/METADATA +48 -21
- {maps4fs-1.7.1.dist-info → maps4fs-1.7.3.dist-info}/RECORD +11 -10
- {maps4fs-1.7.1.dist-info → maps4fs-1.7.3.dist-info}/LICENSE.md +0 -0
- {maps4fs-1.7.1.dist-info → maps4fs-1.7.3.dist-info}/WHEEL +0 -0
- {maps4fs-1.7.1.dist-info → maps4fs-1.7.3.dist-info}/top_level.txt +0 -0
maps4fs/__init__.py
CHANGED
@@ -1,7 +1,8 @@
|
|
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
|
5
6
|
from maps4fs.generator.game import Game
|
6
7
|
from maps4fs.generator.map import Map
|
7
8
|
from maps4fs.generator.settings import (
|
@@ -9,6 +10,7 @@ from maps4fs.generator.settings import (
|
|
9
10
|
DEMSettings,
|
10
11
|
GRLESettings,
|
11
12
|
I3DSettings,
|
13
|
+
SatelliteSettings,
|
12
14
|
SettingsModel,
|
13
15
|
SplineSettings,
|
14
16
|
TextureSettings,
|
maps4fs/generator/dtm/dtm.py
CHANGED
@@ -11,7 +11,10 @@ from typing import TYPE_CHECKING, Type
|
|
11
11
|
import numpy as np
|
12
12
|
import osmnx as ox # type: ignore
|
13
13
|
import rasterio # type: ignore
|
14
|
-
import
|
14
|
+
from rasterio.warp import calculate_default_transform, reproject
|
15
|
+
from rasterio.enums import Resampling
|
16
|
+
from rasterio.merge import merge
|
17
|
+
|
15
18
|
from pydantic import BaseModel
|
16
19
|
|
17
20
|
from maps4fs.logger import Logger
|
@@ -23,8 +26,11 @@ if TYPE_CHECKING:
|
|
23
26
|
class DTMProviderSettings(BaseModel):
|
24
27
|
"""Base class for DTM provider settings models."""
|
25
28
|
|
29
|
+
easy_mode: bool = True
|
30
|
+
power_factor: int = 0
|
31
|
+
|
26
32
|
|
27
|
-
# pylint: disable=too-many-public-methods
|
33
|
+
# pylint: disable=too-many-public-methods, too-many-instance-attributes
|
28
34
|
class DTMProvider(ABC):
|
29
35
|
"""Base class for DTM providers."""
|
30
36
|
|
@@ -44,6 +50,21 @@ class DTMProvider(ABC):
|
|
44
50
|
|
45
51
|
_instructions: str | None = None
|
46
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
|
+
|
47
68
|
# pylint: disable=R0913, R0917
|
48
69
|
def __init__(
|
49
70
|
self,
|
@@ -169,6 +190,15 @@ class DTMProvider(ABC):
|
|
169
190
|
"""
|
170
191
|
return cls._instructions
|
171
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
|
+
|
172
202
|
@property
|
173
203
|
def user_settings(self) -> DTMProviderSettings | None:
|
174
204
|
"""User settings of the provider.
|
@@ -212,63 +242,116 @@ class DTMProvider(ABC):
|
|
212
242
|
"""
|
213
243
|
providers = {}
|
214
244
|
for provider in cls.__subclasses__():
|
215
|
-
|
216
|
-
providers[provider._code] = provider.description() # pylint: disable=W0212
|
245
|
+
providers[provider._code] = provider.description() # pylint: disable=W0212
|
217
246
|
return providers # type: ignore
|
218
247
|
|
219
|
-
|
220
|
-
|
221
|
-
|
222
|
-
Arguments:
|
223
|
-
output_path (str): Path to save the downloaded tile.
|
248
|
+
@abstractmethod
|
249
|
+
def download_tiles(self) -> list[str]:
|
250
|
+
"""Download tiles from the provider.
|
224
251
|
|
225
252
|
Returns:
|
226
|
-
|
253
|
+
list: List of paths to the downloaded tiles.
|
227
254
|
"""
|
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.
|
255
|
+
raise NotImplementedError
|
239
256
|
|
240
|
-
|
241
|
-
|
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.
|
242
261
|
|
243
262
|
Returns:
|
244
|
-
|
245
|
-
not downloaded.
|
263
|
+
np.ndarray: Numpy array of the tile.
|
246
264
|
"""
|
247
|
-
|
248
|
-
|
249
|
-
|
250
|
-
|
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)
|
251
288
|
|
252
|
-
|
253
|
-
|
254
|
-
|
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.
|
255
296
|
|
256
297
|
Returns:
|
257
|
-
|
298
|
+
np.ndarray: Processed elevation data.
|
258
299
|
"""
|
259
|
-
|
300
|
+
self.data_info = {}
|
301
|
+
self.add_numpy_params(data, "original")
|
260
302
|
|
261
|
-
|
262
|
-
|
263
|
-
|
264
|
-
|
265
|
-
|
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.
|
266
348
|
|
267
349
|
Returns:
|
268
|
-
|
350
|
+
dict[str, int | str | float] | None: Information sequence for the component.
|
269
351
|
"""
|
270
|
-
|
352
|
+
return self.data_info
|
271
353
|
|
354
|
+
# region helpers
|
272
355
|
def get_bbox(self) -> tuple[float, float, float, float]:
|
273
356
|
"""Get bounding box of the tile based on the center point and size.
|
274
357
|
|
@@ -281,6 +364,83 @@ class DTMProvider(ABC):
|
|
281
364
|
bbox = north, south, east, west
|
282
365
|
return bbox
|
283
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
|
+
|
284
444
|
def extract_roi(self, tile_path: str) -> np.ndarray:
|
285
445
|
"""Extract region of interest (ROI) from the GeoTIFF file.
|
286
446
|
|
@@ -304,18 +464,86 @@ class DTMProvider(ABC):
|
|
304
464
|
window.width,
|
305
465
|
window.height,
|
306
466
|
)
|
307
|
-
data = src.read(1, window=window)
|
467
|
+
data = src.read(1, window=window, masked=True)
|
308
468
|
if not data.size > 0:
|
309
469
|
raise ValueError("No data in the tile.")
|
310
470
|
|
311
471
|
return data
|
312
472
|
|
313
|
-
def
|
314
|
-
"""
|
315
|
-
|
316
|
-
|
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.
|
317
478
|
|
318
479
|
Returns:
|
319
|
-
|
480
|
+
np.ndarray: Normalized DEM data.
|
320
481
|
"""
|
321
|
-
|
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
|
@@ -0,0 +1,127 @@
|
|
1
|
+
"""This module contains provider of USGS data."""
|
2
|
+
|
3
|
+
import os
|
4
|
+
|
5
|
+
import numpy as np
|
6
|
+
from owslib.wcs import WebCoverageService
|
7
|
+
from owslib.util import Authentication
|
8
|
+
from pyproj import Transformer
|
9
|
+
|
10
|
+
from maps4fs.generator.dtm.dtm import DTMProvider, DTMProviderSettings
|
11
|
+
|
12
|
+
|
13
|
+
class NRWProviderSettings(DTMProviderSettings):
|
14
|
+
"""Settings for the USGS provider."""
|
15
|
+
|
16
|
+
|
17
|
+
# pylint: disable=too-many-locals
|
18
|
+
class NRWProvider(DTMProvider):
|
19
|
+
"""Generic provider of WCS sources."""
|
20
|
+
|
21
|
+
_code = "NRW"
|
22
|
+
_name = "North Rhine-Westphalia DGM1"
|
23
|
+
_region = "DE"
|
24
|
+
_icon = "🇩🇪"
|
25
|
+
_resolution = 1
|
26
|
+
_data: np.ndarray | None = None
|
27
|
+
_settings = NRWProviderSettings
|
28
|
+
_author = "[kbrandwijk](https://github.com/kbrandwijk)"
|
29
|
+
_is_community = True
|
30
|
+
_instructions = None
|
31
|
+
|
32
|
+
def __init__(self, *args, **kwargs):
|
33
|
+
super().__init__(*args, **kwargs)
|
34
|
+
self.shared_tiff_path = os.path.join(self._tile_directory, "shared")
|
35
|
+
os.makedirs(self.shared_tiff_path, exist_ok=True)
|
36
|
+
|
37
|
+
def download_tiles(self) -> list[str]:
|
38
|
+
bbox = self.get_bbox()
|
39
|
+
bbox = self.transform_bbox(bbox, "EPSG:25832")
|
40
|
+
|
41
|
+
tiles = self.tile_bbox(bbox, 1000)
|
42
|
+
|
43
|
+
all_tif_files = self.download_all_tiles(tiles)
|
44
|
+
return all_tif_files
|
45
|
+
|
46
|
+
def tile_bbox(
|
47
|
+
self,
|
48
|
+
bbox: tuple[float, float, float, float],
|
49
|
+
tile_size: int) -> list[tuple[float, float, float, float]]:
|
50
|
+
"""Tile the bounding box into smaller bounding boxes of a specified size.
|
51
|
+
|
52
|
+
Arguments:
|
53
|
+
bbox (tuple): Bounding box to tile (north, south, east, west).
|
54
|
+
tile_size (int): Size of the tiles in meters.
|
55
|
+
|
56
|
+
Returns:
|
57
|
+
list: List of smaller bounding boxes (north, south, east, west).
|
58
|
+
"""
|
59
|
+
north, south, east, west = bbox
|
60
|
+
x_coords = np.arange(west, east, tile_size)
|
61
|
+
y_coords = np.arange(south, north, tile_size)
|
62
|
+
x_coords = np.append(x_coords, east).astype(x_coords.dtype)
|
63
|
+
y_coords = np.append(y_coords, north).astype(y_coords.dtype)
|
64
|
+
|
65
|
+
x_min, y_min = np.meshgrid(x_coords[:-1], y_coords[:-1], indexing="ij")
|
66
|
+
x_max, y_max = np.meshgrid(x_coords[1:], y_coords[1:], indexing="ij")
|
67
|
+
|
68
|
+
tiles = np.stack([x_min.ravel(), y_min.ravel(), x_max.ravel(), y_max.ravel()], axis=1)
|
69
|
+
|
70
|
+
return tiles
|
71
|
+
|
72
|
+
def download_all_tiles(self, tiles: list[tuple[float, float, float, float]]) -> list[str]:
|
73
|
+
"""Download tiles from the NRW provider.
|
74
|
+
|
75
|
+
Arguments:
|
76
|
+
tiles (list): List of tiles to download.
|
77
|
+
|
78
|
+
Returns:
|
79
|
+
list: List of paths to the downloaded GeoTIFF files.
|
80
|
+
"""
|
81
|
+
all_tif_files = []
|
82
|
+
wcs = WebCoverageService(
|
83
|
+
'https://www.wcs.nrw.de/geobasis/wcs_nw_dgm',
|
84
|
+
auth=Authentication(verify=False),
|
85
|
+
timeout=600)
|
86
|
+
for tile in tiles:
|
87
|
+
file_name = '_'.join(map(str, tile)) + '.tif'
|
88
|
+
file_path = os.path.join(self.shared_tiff_path, file_name)
|
89
|
+
|
90
|
+
if not os.path.exists(file_path):
|
91
|
+
output = wcs.getCoverage(
|
92
|
+
identifier=['nw_dgm'],
|
93
|
+
subsets=[('y', str(tile[0]), str(tile[2])), ('x', str(tile[1]), str(tile[3]))],
|
94
|
+
format='image/tiff'
|
95
|
+
)
|
96
|
+
with open(file_path, 'wb') as f:
|
97
|
+
f.write(output.read())
|
98
|
+
|
99
|
+
all_tif_files.append(file_path)
|
100
|
+
return all_tif_files
|
101
|
+
|
102
|
+
def transform_bbox(
|
103
|
+
self,
|
104
|
+
bbox: tuple[float, float, float, float],
|
105
|
+
to_crs: str) -> tuple[float, float, float, float]:
|
106
|
+
"""Transform the bounding box to a different coordinate reference system (CRS).
|
107
|
+
|
108
|
+
Arguments:
|
109
|
+
bbox (tuple): Bounding box to transform (north, south, east, west).
|
110
|
+
to_crs (str): Target CRS (e.g., EPSG:4326 for CRS:84).
|
111
|
+
|
112
|
+
Returns:
|
113
|
+
tuple: Transformed bounding box (north, south, east, west).
|
114
|
+
"""
|
115
|
+
transformer = Transformer.from_crs("epsg:4326", to_crs)
|
116
|
+
north, south, east, west = bbox
|
117
|
+
bottom_left_x, bottom_left_y = transformer.transform(xx=south, yy=west)
|
118
|
+
top_left_x, top_left_y = transformer.transform(xx=north, yy=west)
|
119
|
+
top_right_x, top_right_y = transformer.transform(xx=north, yy=east)
|
120
|
+
bottom_right_x, bottom_right_y = transformer.transform(xx=south, yy=east)
|
121
|
+
|
122
|
+
west = min(bottom_left_y, bottom_right_y)
|
123
|
+
east = max(top_left_y, top_right_y)
|
124
|
+
south = min(bottom_left_x, top_left_x)
|
125
|
+
north = max(bottom_right_x, top_right_x)
|
126
|
+
|
127
|
+
return north, south, east, west
|
maps4fs/generator/dtm/srtm.py
CHANGED
@@ -7,7 +7,7 @@ import math
|
|
7
7
|
import os
|
8
8
|
import shutil
|
9
9
|
|
10
|
-
import
|
10
|
+
import requests
|
11
11
|
|
12
12
|
from maps4fs.generator.dtm.dtm import DTMProvider, DTMProviderSettings
|
13
13
|
|
@@ -15,9 +15,6 @@ from maps4fs.generator.dtm.dtm import DTMProvider, DTMProviderSettings
|
|
15
15
|
class SRTM30ProviderSettings(DTMProviderSettings):
|
16
16
|
"""Settings for SRTM 30m provider."""
|
17
17
|
|
18
|
-
easy_mode: bool = True
|
19
|
-
power_factor: int = 0
|
20
|
-
|
21
18
|
|
22
19
|
class SRTM30Provider(DTMProvider):
|
23
20
|
"""Provider of Shuttle Radar Topography Mission (SRTM) 30m data."""
|
@@ -32,22 +29,6 @@ class SRTM30Provider(DTMProvider):
|
|
32
29
|
|
33
30
|
_author = "[iwatkot](https://github.com/iwatkot)"
|
34
31
|
|
35
|
-
_instructions = (
|
36
|
-
"ℹ️ If you don't know how to work with DEM data, it is recommended to use the "
|
37
|
-
"**Easy mode** option. It will automatically change the values in the image, so the "
|
38
|
-
"terrain will be visible in the Giants Editor. If you're an experienced modder, it's "
|
39
|
-
"recommended to disable this option and work with the DEM data in a usual way. \n"
|
40
|
-
"ℹ️ If the terrain height difference in the real world is bigger than 255 meters, "
|
41
|
-
"the [Height scale](https://github.com/iwatkot/maps4fs/blob/main/docs/dem.md#height-scale)"
|
42
|
-
" parameter in the **map.i3d** file will be changed automatically. \n"
|
43
|
-
"⚡ If the **Easy mode** option is disabled, you will probably get completely flat "
|
44
|
-
"terrain, unless you adjust the DEM Multiplier Setting or the Height scale parameter in "
|
45
|
-
"the Giants Editor. \n"
|
46
|
-
"💡 You can use the **Power factor** setting to make the difference between heights "
|
47
|
-
"bigger. Be extremely careful with this setting, and use only low values, otherwise your "
|
48
|
-
"terrain may be completely broken. \n"
|
49
|
-
)
|
50
|
-
|
51
32
|
_settings = SRTM30ProviderSettings
|
52
33
|
|
53
34
|
def __init__(self, *args, **kwargs):
|
@@ -58,169 +39,86 @@ class SRTM30Provider(DTMProvider):
|
|
58
39
|
os.makedirs(self.gz_directory, exist_ok=True)
|
59
40
|
self.data_info: dict[str, int | str | float] | None = None # type: ignore
|
60
41
|
|
61
|
-
def
|
62
|
-
"""
|
42
|
+
def download_tiles(self):
|
43
|
+
"""Download SRTM tiles."""
|
44
|
+
north, south, east, west = self.get_bbox()
|
63
45
|
|
64
|
-
|
65
|
-
|
66
|
-
|
46
|
+
tiles = []
|
47
|
+
# Look at each corner of the bbox in case the bbox spans across multiple tiles
|
48
|
+
for pair in [(north, east), (south, west), (south, east), (north, west)]:
|
49
|
+
tile_parameters = self.get_tile_parameters(*pair)
|
50
|
+
tile_name = tile_parameters["tile_name"]
|
51
|
+
decompressed_tile_path = os.path.join(self.hgt_directory, f"{tile_name}.hgt")
|
67
52
|
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
53
|
+
if not os.path.isfile(decompressed_tile_path):
|
54
|
+
compressed_tile_path = os.path.join(self.gz_directory, f"{tile_name}.hgt.gz")
|
55
|
+
if not self.get_or_download_tile(compressed_tile_path, **tile_parameters):
|
56
|
+
raise FileNotFoundError(f"Tile {tile_name} not found.")
|
72
57
|
|
73
|
-
|
74
|
-
|
58
|
+
with gzip.open(compressed_tile_path, "rb") as f_in:
|
59
|
+
with open(decompressed_tile_path, "wb") as f_out:
|
60
|
+
shutil.copyfileobj(f_in, f_out)
|
61
|
+
tiles.append(decompressed_tile_path)
|
75
62
|
|
76
|
-
|
77
|
-
if lon < 0:
|
78
|
-
tile_name = f"{latitude_band}W{abs(tile_longitude):03d}"
|
79
|
-
else:
|
80
|
-
tile_name = f"{latitude_band}E{abs(tile_longitude):03d}"
|
63
|
+
return tiles
|
81
64
|
|
82
|
-
|
83
|
-
|
84
|
-
|
85
|
-
return {"latitude_band": latitude_band, "tile_name": tile_name}
|
65
|
+
# region provider specific helpers
|
66
|
+
def download_tile(self, output_path: str, **kwargs) -> bool:
|
67
|
+
"""Download a tile from the provider.
|
86
68
|
|
87
|
-
|
88
|
-
|
69
|
+
Arguments:
|
70
|
+
output_path (str): Path to save the downloaded tile.
|
89
71
|
|
90
72
|
Returns:
|
91
|
-
|
92
|
-
"""
|
93
|
-
tile_parameters = self.get_tile_parameters(*self.coordinates)
|
94
|
-
tile_name = tile_parameters["tile_name"]
|
95
|
-
decompressed_tile_path = os.path.join(self.hgt_directory, f"{tile_name}.hgt")
|
96
|
-
|
97
|
-
if not os.path.isfile(decompressed_tile_path):
|
98
|
-
compressed_tile_path = os.path.join(self.gz_directory, f"{tile_name}.hgt.gz")
|
99
|
-
if not self.get_or_download_tile(compressed_tile_path, **tile_parameters):
|
100
|
-
raise FileNotFoundError(f"Tile {tile_name} not found.")
|
101
|
-
|
102
|
-
with gzip.open(compressed_tile_path, "rb") as f_in:
|
103
|
-
with open(decompressed_tile_path, "wb") as f_out:
|
104
|
-
shutil.copyfileobj(f_in, f_out)
|
105
|
-
|
106
|
-
data = self.extract_roi(decompressed_tile_path)
|
107
|
-
|
108
|
-
self.data_info = {}
|
109
|
-
self.add_numpy_params(data, "original")
|
110
|
-
|
111
|
-
data = self.signed_to_unsigned(data)
|
112
|
-
self.add_numpy_params(data, "grounded")
|
113
|
-
|
114
|
-
original_deviation = int(self.data_info["original_deviation"])
|
115
|
-
in_game_maximum_height = 65535 // 255
|
116
|
-
if original_deviation > in_game_maximum_height:
|
117
|
-
suggested_height_scale_multiplier = (
|
118
|
-
original_deviation / in_game_maximum_height # type: ignore
|
119
|
-
)
|
120
|
-
suggested_height_scale_value = int(255 * suggested_height_scale_multiplier)
|
121
|
-
else:
|
122
|
-
suggested_height_scale_multiplier = 1
|
123
|
-
suggested_height_scale_value = 255
|
124
|
-
|
125
|
-
self.data_info["suggested_height_scale_multiplier"] = suggested_height_scale_multiplier
|
126
|
-
self.data_info["suggested_height_scale_value"] = suggested_height_scale_value
|
127
|
-
|
128
|
-
self.map.shared_settings.height_scale_multiplier = ( # type: ignore
|
129
|
-
suggested_height_scale_multiplier
|
130
|
-
)
|
131
|
-
self.map.shared_settings.height_scale_value = suggested_height_scale_value # type: ignore
|
132
|
-
|
133
|
-
if self.user_settings.easy_mode: # type: ignore
|
134
|
-
try:
|
135
|
-
data = self.normalize_dem(data)
|
136
|
-
self.add_numpy_params(data, "normalized")
|
137
|
-
|
138
|
-
normalized_deviation = self.data_info["normalized_deviation"]
|
139
|
-
z_scaling_factor = normalized_deviation / original_deviation # type: ignore
|
140
|
-
self.data_info["z_scaling_factor"] = z_scaling_factor
|
141
|
-
|
142
|
-
self.map.shared_settings.mesh_z_scaling_factor = z_scaling_factor # type: ignore
|
143
|
-
self.map.shared_settings.change_height_scale = True # type: ignore
|
144
|
-
|
145
|
-
except Exception as e: # pylint: disable=W0718
|
146
|
-
self.logger.error(
|
147
|
-
"Failed to normalize DEM data. Error: %s. Using original data.", e
|
148
|
-
)
|
149
|
-
|
150
|
-
return data
|
151
|
-
|
152
|
-
def add_numpy_params(
|
153
|
-
self,
|
154
|
-
data: np.ndarray,
|
155
|
-
prefix: str,
|
156
|
-
) -> None:
|
157
|
-
"""Add numpy array parameters to the data_info dictionary.
|
158
|
-
|
159
|
-
Arguments:
|
160
|
-
data (np.ndarray): Numpy array of the tile.
|
161
|
-
prefix (str): Prefix for the parameters.
|
73
|
+
bool: True if the tile was downloaded successfully, False otherwise.
|
162
74
|
"""
|
163
|
-
|
164
|
-
|
165
|
-
|
166
|
-
|
167
|
-
|
168
|
-
|
169
|
-
|
75
|
+
url = self.formatted_url(**kwargs)
|
76
|
+
response = requests.get(url, stream=True, timeout=10)
|
77
|
+
if response.status_code == 200:
|
78
|
+
with open(output_path, "wb") as file:
|
79
|
+
for chunk in response.iter_content(chunk_size=1024):
|
80
|
+
file.write(chunk)
|
81
|
+
return True
|
82
|
+
return False
|
83
|
+
|
84
|
+
def get_or_download_tile(self, output_path: str, **kwargs) -> str | None:
|
85
|
+
"""Get or download a tile from the provider.
|
170
86
|
|
171
87
|
Arguments:
|
172
|
-
|
88
|
+
output_path (str): Path to save the downloaded tile.
|
173
89
|
|
174
90
|
Returns:
|
175
|
-
|
91
|
+
str: Path to the downloaded tile or None if the tile not exists and was
|
92
|
+
not downloaded.
|
176
93
|
"""
|
177
|
-
|
178
|
-
|
179
|
-
|
180
|
-
return
|
94
|
+
if not os.path.exists(output_path):
|
95
|
+
if not self.download_tile(output_path, **kwargs):
|
96
|
+
return None
|
97
|
+
return output_path
|
181
98
|
|
182
|
-
def
|
183
|
-
"""
|
99
|
+
def get_tile_parameters(self, *args) -> dict[str, str]:
|
100
|
+
"""Returns latitude band and tile name for SRTM tile from coordinates.
|
184
101
|
|
185
102
|
Arguments:
|
186
|
-
|
103
|
+
lat (float): Latitude.
|
104
|
+
lon (float): Longitude.
|
187
105
|
|
188
106
|
Returns:
|
189
|
-
|
107
|
+
dict: Tile parameters.
|
190
108
|
"""
|
191
|
-
|
192
|
-
minimum_height = int(data.min())
|
193
|
-
deviation = maximum_height - minimum_height
|
194
|
-
self.logger.debug(
|
195
|
-
"Maximum height: %s. Minimum height: %s. Deviation: %s.",
|
196
|
-
maximum_height,
|
197
|
-
minimum_height,
|
198
|
-
deviation,
|
199
|
-
)
|
200
|
-
self.logger.debug("Number of unique values in original DEM data: %s.", np.unique(data).size)
|
109
|
+
lat, lon = args
|
201
110
|
|
202
|
-
|
203
|
-
|
204
|
-
scaling_factor = adjusted_maximum_height / maximum_height
|
205
|
-
self.logger.debug(
|
206
|
-
"Adjusted maximum height: %s. Scaling factor: %s.",
|
207
|
-
adjusted_maximum_height,
|
208
|
-
scaling_factor,
|
209
|
-
)
|
111
|
+
tile_latitude = math.floor(lat)
|
112
|
+
tile_longitude = math.floor(lon)
|
210
113
|
|
211
|
-
|
212
|
-
|
213
|
-
|
214
|
-
|
215
|
-
|
216
|
-
)
|
217
|
-
data = np.power(data, power_factor).astype(np.uint16)
|
114
|
+
latitude_band = f"N{abs(tile_latitude):02d}" if lat >= 0 else f"S{abs(tile_latitude):02d}"
|
115
|
+
if lon < 0:
|
116
|
+
tile_name = f"{latitude_band}W{abs(tile_longitude):03d}"
|
117
|
+
else:
|
118
|
+
tile_name = f"{latitude_band}E{abs(tile_longitude):03d}"
|
218
119
|
|
219
|
-
normalized_data = np.round(data * scaling_factor).astype(np.uint16)
|
220
120
|
self.logger.debug(
|
221
|
-
"
|
222
|
-
normalized_data.max(),
|
223
|
-
normalized_data.min(),
|
224
|
-
np.unique(normalized_data).size,
|
121
|
+
"Detected tile name: %s for coordinates: lat %s, lon %s.", tile_name, lat, lon
|
225
122
|
)
|
226
|
-
return
|
123
|
+
return {"latitude_band": latitude_band, "tile_name": tile_name}
|
124
|
+
# endregion
|
maps4fs/generator/dtm/usgs.py
CHANGED
@@ -5,12 +5,7 @@ from datetime import datetime
|
|
5
5
|
from zipfile import ZipFile
|
6
6
|
|
7
7
|
import numpy as np
|
8
|
-
import rasterio
|
9
8
|
import requests
|
10
|
-
from rasterio.enums import Resampling
|
11
|
-
from rasterio.merge import merge
|
12
|
-
from rasterio.warp import calculate_default_transform, reproject
|
13
|
-
from rasterio.windows import from_bounds
|
14
9
|
|
15
10
|
from maps4fs.generator.dtm.dtm import DTMProvider, DTMProviderSettings
|
16
11
|
|
@@ -18,7 +13,6 @@ from maps4fs.generator.dtm.dtm import DTMProvider, DTMProviderSettings
|
|
18
13
|
class USGSProviderSettings(DTMProviderSettings):
|
19
14
|
"""Settings for the USGS provider."""
|
20
15
|
|
21
|
-
max_local_elevation: int = 255
|
22
16
|
dataset: tuple | str = (
|
23
17
|
'Digital Elevation Model (DEM) 1 meter',
|
24
18
|
'Alaska IFSAR 5 meter DEM',
|
@@ -43,17 +37,18 @@ class USGSProvider(DTMProvider):
|
|
43
37
|
_author = "[ZenJakey](https://github.com/ZenJakey)"
|
44
38
|
_contributors = "[kbrandwijk](https://github.com/kbrandwijk)"
|
45
39
|
_is_community = True
|
46
|
-
_instructions =
|
47
|
-
"ℹ️ Set the max local elevation to approx the local max elevation for your area in"
|
48
|
-
" meters. This will allow you to use heightScale 255 in GE with minimal tweaking."
|
49
|
-
" Setting this value too low can cause a flat map!"
|
50
|
-
)
|
40
|
+
_instructions = None
|
51
41
|
|
52
42
|
_url = (
|
53
43
|
"https://tnmaccess.nationalmap.gov/api/v1/products?prodFormats=GeoTIFF,IMG"
|
54
44
|
|
55
45
|
)
|
56
46
|
|
47
|
+
def download_tiles(self):
|
48
|
+
download_urls = self.get_download_urls()
|
49
|
+
all_tif_files = self.download_tif_files(download_urls)
|
50
|
+
return all_tif_files
|
51
|
+
|
57
52
|
def __init__(self, *args, **kwargs):
|
58
53
|
super().__init__(*args, **kwargs)
|
59
54
|
timestamp = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
|
@@ -138,214 +133,3 @@ class USGSProvider(DTMProvider):
|
|
138
133
|
tif_files.append(file_path)
|
139
134
|
|
140
135
|
return tif_files
|
141
|
-
|
142
|
-
def merge_geotiff(self, input_files: list[str], output_file: str) -> None:
|
143
|
-
"""Merge multiple GeoTIFF files into a single GeoTIFF file.
|
144
|
-
|
145
|
-
Arguments:
|
146
|
-
input_files (list): List of input GeoTIFF files to merge.
|
147
|
-
output_file (str): Path to save the merged GeoTIFF file.
|
148
|
-
"""
|
149
|
-
# Open all input GeoTIFF files as datasets
|
150
|
-
self.logger.debug("Merging tiff files...")
|
151
|
-
datasets = [rasterio.open(file) for file in input_files]
|
152
|
-
|
153
|
-
# Merge datasets
|
154
|
-
mosaic, out_transform = merge(datasets, nodata=0)
|
155
|
-
|
156
|
-
# Get metadata from the first file and update it for the output
|
157
|
-
out_meta = datasets[0].meta.copy()
|
158
|
-
out_meta.update(
|
159
|
-
{
|
160
|
-
"driver": "GTiff",
|
161
|
-
"height": mosaic.shape[1],
|
162
|
-
"width": mosaic.shape[2],
|
163
|
-
"transform": out_transform,
|
164
|
-
"count": mosaic.shape[0], # Number of bands
|
165
|
-
}
|
166
|
-
)
|
167
|
-
|
168
|
-
# Write merged GeoTIFF to the output file
|
169
|
-
with rasterio.open(output_file, "w", **out_meta) as dest:
|
170
|
-
dest.write(mosaic)
|
171
|
-
|
172
|
-
self.logger.debug("GeoTIFF images merged successfully into %s", output_file)
|
173
|
-
|
174
|
-
def reproject_geotiff(self, input_tiff: str, output_tiff: str, target_crs: str) -> None:
|
175
|
-
"""Reproject a GeoTIFF file to a new coordinate reference system (CRS).
|
176
|
-
|
177
|
-
Arguments:
|
178
|
-
input_tiff (str): Path to the input GeoTIFF file.
|
179
|
-
output_tiff (str): Path to save the reprojected GeoTIFF file.
|
180
|
-
target_crs (str): Target CRS (e.g., EPSG:4326 for CRS:84).
|
181
|
-
"""
|
182
|
-
# Open the source GeoTIFF
|
183
|
-
self.logger.debug("Reprojecting GeoTIFF to %s CRS...", target_crs)
|
184
|
-
with rasterio.open(input_tiff) as src:
|
185
|
-
# Get the transform, width, and height of the target CRS
|
186
|
-
transform, width, height = calculate_default_transform(
|
187
|
-
src.crs, target_crs, src.width, src.height, *src.bounds
|
188
|
-
)
|
189
|
-
|
190
|
-
# Update the metadata for the target GeoTIFF
|
191
|
-
kwargs = src.meta.copy()
|
192
|
-
kwargs.update(
|
193
|
-
{"crs": target_crs, "transform": transform, "width": width, "height": height}
|
194
|
-
)
|
195
|
-
|
196
|
-
# Open the destination GeoTIFF file and reproject
|
197
|
-
with rasterio.open(output_tiff, "w", **kwargs) as dst:
|
198
|
-
for i in range(1, src.count + 1): # Iterate over all raster bands
|
199
|
-
reproject(
|
200
|
-
source=rasterio.band(src, i),
|
201
|
-
destination=rasterio.band(dst, i),
|
202
|
-
src_transform=src.transform,
|
203
|
-
src_crs=src.crs,
|
204
|
-
dst_transform=transform,
|
205
|
-
dst_crs=target_crs,
|
206
|
-
resampling=Resampling.nearest, # Choose resampling method
|
207
|
-
)
|
208
|
-
self.logger.debug("Reprojected GeoTIFF saved to %s", output_tiff)
|
209
|
-
|
210
|
-
def extract_roi(self, input_tiff: str) -> np.ndarray: # pylint: disable=W0237
|
211
|
-
"""
|
212
|
-
Crop a GeoTIFF based on given geographic bounding box and save to a new file.
|
213
|
-
|
214
|
-
Arguments:
|
215
|
-
input_tiff (str): Path to the input GeoTIFF file.
|
216
|
-
|
217
|
-
Returns:
|
218
|
-
np.ndarray: Numpy array of the cropped GeoTIFF.
|
219
|
-
"""
|
220
|
-
self.logger.debug("Extracting ROI...")
|
221
|
-
# Open the input GeoTIFF
|
222
|
-
with rasterio.open(input_tiff) as src:
|
223
|
-
|
224
|
-
# Create a rasterio window from the bounding box
|
225
|
-
(north, south, east, west) = self.get_bbox()
|
226
|
-
window = from_bounds(west, south, east, north, transform=src.transform)
|
227
|
-
|
228
|
-
data = src.read(1, window=window)
|
229
|
-
self.logger.debug("Extracted ROI")
|
230
|
-
return data
|
231
|
-
|
232
|
-
# pylint: disable=R0914, R0917, R0913
|
233
|
-
def convert_geotiff_to_geotiff(
|
234
|
-
self,
|
235
|
-
input_tiff: str,
|
236
|
-
output_tiff: str,
|
237
|
-
min_height: float,
|
238
|
-
max_height: float,
|
239
|
-
target_crs: str,
|
240
|
-
) -> None:
|
241
|
-
"""
|
242
|
-
Convert a GeoTIFF to a scaled GeoTIFF with UInt16 values using a specific coordinate
|
243
|
-
system and output size.
|
244
|
-
|
245
|
-
Arguments:
|
246
|
-
input_tiff (str): Path to the input GeoTIFF file.
|
247
|
-
output_tiff (str): Path to save the output GeoTIFF file.
|
248
|
-
min_height (float): Minimum terrain height (input range).
|
249
|
-
max_height (float): Maximum terrain height (input range).
|
250
|
-
target_crs (str): Target CRS (e.g., EPSG:4326 for CRS:84).
|
251
|
-
"""
|
252
|
-
# Open the input GeoTIFF file
|
253
|
-
self.logger.debug("Converting to uint16")
|
254
|
-
with rasterio.open(input_tiff) as src:
|
255
|
-
# Ensure the input CRS matches the target CRS (reprojection may be required)
|
256
|
-
if str(src.crs) != str(target_crs):
|
257
|
-
raise ValueError(
|
258
|
-
f"The GeoTIFF CRS is {src.crs}, but the target CRS is {target_crs}. "
|
259
|
-
"Reprojection may be required."
|
260
|
-
)
|
261
|
-
|
262
|
-
# Read the data from the first band
|
263
|
-
data = src.read(1) # Assuming the input GeoTIFF has only a single band
|
264
|
-
|
265
|
-
# Identify the input file's NoData value
|
266
|
-
input_nodata = src.nodata
|
267
|
-
if input_nodata is None:
|
268
|
-
input_nodata = -999999.0 # Default fallback if no NoData value is defined
|
269
|
-
nodata_value = 0
|
270
|
-
# Replace NoData values (e.g., -999999.0) with the new NoData value
|
271
|
-
# (e.g., 65535 for UInt16)
|
272
|
-
data[data == input_nodata] = nodata_value
|
273
|
-
|
274
|
-
# Scale the data to the 0–65535 range (UInt16), avoiding NoData areas
|
275
|
-
scaled_data = np.clip(
|
276
|
-
(data - min_height) * (65535 / (max_height - min_height)), 0, 65535
|
277
|
-
).astype(np.uint16)
|
278
|
-
scaled_data[data == nodata_value] = (
|
279
|
-
nodata_value # Preserve NoData value in the scaled array
|
280
|
-
)
|
281
|
-
|
282
|
-
# Compute the proper transform to ensure consistency
|
283
|
-
# Get the original transform, width, and height
|
284
|
-
transform = src.transform
|
285
|
-
width = src.width
|
286
|
-
height = src.height
|
287
|
-
left, bottom, right, top = src.bounds
|
288
|
-
|
289
|
-
# Adjust the transform matrix to make sure bounds and transform align correctly
|
290
|
-
transform = rasterio.transform.from_bounds(left, bottom, right, top, width, height)
|
291
|
-
|
292
|
-
# Prepare metadata for the output GeoTIFF
|
293
|
-
metadata = src.meta.copy()
|
294
|
-
metadata.update(
|
295
|
-
{
|
296
|
-
"dtype": rasterio.uint16, # Update dtype for uint16
|
297
|
-
"crs": target_crs, # Update CRS if needed
|
298
|
-
"nodata": nodata_value, # Set the new NoData value
|
299
|
-
"transform": transform, # Use the updated, consistent transform
|
300
|
-
}
|
301
|
-
)
|
302
|
-
|
303
|
-
# Write the scaled data to the output GeoTIFF
|
304
|
-
with rasterio.open(output_tiff, "w", **metadata) as dst:
|
305
|
-
dst.write(scaled_data, 1) # Write the first band
|
306
|
-
|
307
|
-
self.logger.debug(
|
308
|
-
"GeoTIFF successfully converted and saved to %s, with nodata value: %s.",
|
309
|
-
output_tiff,
|
310
|
-
nodata_value,
|
311
|
-
)
|
312
|
-
|
313
|
-
def generate_data(self) -> np.ndarray:
|
314
|
-
"""Generate data from the USGS 1m provider.
|
315
|
-
|
316
|
-
Returns:
|
317
|
-
np.ndarray: Numpy array of the data.
|
318
|
-
"""
|
319
|
-
download_urls = self.get_download_urls()
|
320
|
-
all_tif_files = self.download_tif_files(download_urls)
|
321
|
-
self.merge_geotiff(all_tif_files, os.path.join(self.output_path, "merged.tif"))
|
322
|
-
self.reproject_geotiff(
|
323
|
-
os.path.join(self.output_path, "merged.tif"),
|
324
|
-
os.path.join(self.output_path, "reprojected.tif"),
|
325
|
-
"EPSG:4326",
|
326
|
-
)
|
327
|
-
self.convert_geotiff_to_geotiff(
|
328
|
-
os.path.join(self.output_path, "reprojected.tif"),
|
329
|
-
os.path.join(self.output_path, "translated.tif"),
|
330
|
-
min_height=0,
|
331
|
-
max_height=self.user_settings.max_local_elevation, # type: ignore
|
332
|
-
target_crs="EPSG:4326",
|
333
|
-
)
|
334
|
-
return self.extract_roi(os.path.join(self.output_path, "translated.tif"))
|
335
|
-
|
336
|
-
def get_numpy(self) -> np.ndarray:
|
337
|
-
"""Get numpy array of the tile.
|
338
|
-
|
339
|
-
Returns:
|
340
|
-
np.ndarray: Numpy array of the tile.
|
341
|
-
"""
|
342
|
-
if not self.user_settings:
|
343
|
-
raise ValueError("user_settings is 'none'")
|
344
|
-
if self.user_settings.max_local_elevation <= 0: # type: ignore
|
345
|
-
raise ValueError(
|
346
|
-
"Entered 'max_local_elevation' value is unable to be used. "
|
347
|
-
"Use a value greater than 0."
|
348
|
-
)
|
349
|
-
if not self._data:
|
350
|
-
self._data = self.generate_data()
|
351
|
-
return self._data
|
maps4fs/generator/texture.py
CHANGED
@@ -466,7 +466,11 @@ class Texture(Component):
|
|
466
466
|
self.np_to_polygon_points(polygon) # type: ignore
|
467
467
|
)
|
468
468
|
if not layer.invisible:
|
469
|
-
|
469
|
+
try:
|
470
|
+
cv2.fillPoly(layer_image, [polygon], color=255) # type: ignore
|
471
|
+
except Exception as e: # pylint: disable=W0718
|
472
|
+
self.logger.warning("Error drawing polygon: %s.", repr(e))
|
473
|
+
continue
|
470
474
|
|
471
475
|
if layer.info_layer == "roads":
|
472
476
|
for linestring in self.objects_generator(
|
@@ -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> •
|
@@ -141,7 +140,7 @@ Check out the [Docker FAQ](docs/FAQ_docker.md) if you have any questions.<br>
|
|
141
140
|
```bash
|
142
141
|
pip install maps4fs
|
143
142
|
```
|
144
|
-
And refer to the [Python package](#option-3-python-package) section to learn how to use it.<br>
|
143
|
+
And refer to the [Python package or run from the source](#option-3-python-package-or-source-code) section to learn how to use it.<br>
|
145
144
|
|
146
145
|
## Overview
|
147
146
|
The core idea is coming from the awesome [maps4cim](https://github.com/klamann/maps4cim) project.<br>
|
@@ -201,7 +200,7 @@ docker run -d -p 8501:8501 --name maps4fs iwatkot/maps4fs
|
|
201
200
|
4. Fill in the required fields and click on the `Generate` button.
|
202
201
|
5. When the map is generated click on the `Download` button to get the map.
|
203
202
|
|
204
|
-
### Option 3: Python package
|
203
|
+
### Option 3: Python package or source code
|
205
204
|
🔴 Recommended for developers.
|
206
205
|
🗺️ Supported map sizes: 2x2, 4x4, 8x8, 16x16 km and any custom size.
|
207
206
|
⚙️ Advanced settings: enabled.
|
@@ -212,11 +211,50 @@ You can use the Python package to generate maps. Follow these steps:
|
|
212
211
|
```bash
|
213
212
|
pip install maps4fs
|
214
213
|
```
|
214
|
+
|
215
|
+
Or clone the repository and install the package from the source code:
|
216
|
+
```bash
|
217
|
+
git clone https://github.com/iwatkot/maps4fs.git
|
218
|
+
cd maps4fs
|
219
|
+
dev/create_venv.ps1 # for Windows
|
220
|
+
sh dev/create_venv.sh # for Linux
|
221
|
+
|
222
|
+
# Activate the virtual environment.
|
223
|
+
./venv/scripts/activate # for Windows
|
224
|
+
source venv/bin/activate # for Linux
|
225
|
+
|
226
|
+
# Edit the demo.py file to set the parameters.
|
227
|
+
python demo.py
|
228
|
+
```
|
229
|
+
|
230
|
+
|
215
231
|
2. Import the Game class and create an instance of it:
|
216
232
|
```python
|
217
233
|
import maps4fs as mfs
|
218
234
|
|
219
|
-
|
235
|
+
game_code = "fs25"
|
236
|
+
game = mfs.Game.from_code(game_code)
|
237
|
+
|
238
|
+
dtm_provider = mfs.SRTM30Provider
|
239
|
+
dtm_provider_settings = mfs.SRTM30ProviderSettings(easy_mode=True, power_factor=0)
|
240
|
+
|
241
|
+
lat, lon = 45.28, 20.23
|
242
|
+
coordinates = (lat, lon)
|
243
|
+
size = 2048
|
244
|
+
rotation = 25
|
245
|
+
|
246
|
+
map_directory = "map_directory"
|
247
|
+
os.makedirs(map_directory, exist_ok=True)
|
248
|
+
|
249
|
+
mp = mfs.Map(
|
250
|
+
game,
|
251
|
+
dtm_provider,
|
252
|
+
dtm_provider_settings,
|
253
|
+
coordinates,
|
254
|
+
size,
|
255
|
+
rotation,
|
256
|
+
map_directory,
|
257
|
+
)
|
220
258
|
```
|
221
259
|
In this case, the library will use the default templates, which should be present in the `data` directory, which should be placed in the current working directory.<br>
|
222
260
|
Structure example:<br>
|
@@ -229,28 +267,17 @@ Structure example:<br>
|
|
229
267
|
|
230
268
|
So it's recommended to download the `data` directory from the repository and place it in the root of your project.<br>
|
231
269
|
|
232
|
-
3.
|
233
|
-
```python
|
234
|
-
import maps4fs as mfs
|
235
|
-
|
236
|
-
map = mfs.Map(
|
237
|
-
game,
|
238
|
-
(52.5200, 13.4050), # Latitude and longitude of the map center.
|
239
|
-
height=1024, # The height of the map in meters.
|
240
|
-
width=1024, # The width of the map in meters.
|
241
|
-
map_directory="path/to/your/map/directory", # The directory where the map will be saved.
|
242
|
-
)
|
243
|
-
```
|
244
|
-
|
245
|
-
4. Generate the map:
|
270
|
+
3. Launch the generation process.
|
246
271
|
The `generate` method returns a generator, which yields the active component of the map. You can use it to track the progress of the generation process.
|
247
272
|
```python
|
248
|
-
for
|
249
|
-
print(
|
273
|
+
for component_name in mp.generate():
|
274
|
+
print(f"Generating {component_name}...")
|
250
275
|
```
|
251
276
|
|
252
277
|
The map will be saved in the `map_directory` directory.
|
253
278
|
|
279
|
+
➡️ Check out the [demo.py](demo.py) file for a complete example.
|
280
|
+
|
254
281
|
## Modder Toolbox
|
255
282
|
The tool now has a Modder Toolbox, which is a set of tools to help you with various tasks. You can open the toolbox by switching to the `🧰 Modder Toolbox` tab in the StreamLit app.<br>
|
256
283
|
|
@@ -1,4 +1,4 @@
|
|
1
|
-
maps4fs/__init__.py,sha256=
|
1
|
+
maps4fs/__init__.py,sha256=TXqX7Ks_Kqt2fiXRt5zdVSLUHxP4cT_p7jgutYFdbo8,630
|
2
2
|
maps4fs/logger.py,sha256=B-NEYpMjPAAqlV4VpfTi6nbBFnEABVtQOaYe6nMpidg,1489
|
3
3
|
maps4fs/generator/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
|
4
4
|
maps4fs/generator/background.py,sha256=tV4UXvtkNN-OSvv6ujp4jFWRU1xGBgEvSakVGZ1H4nc,24877
|
@@ -12,16 +12,17 @@ maps4fs/generator/map.py,sha256=a50KQEr1XZKjS_WKXywGwh4OC3gyjY6M8FTc0eNcxpg,1018
|
|
12
12
|
maps4fs/generator/qgis.py,sha256=Es8hLuqN_KH8lDfnJE6He2rWYbAKJ3RGPn-o87S6CPI,6116
|
13
13
|
maps4fs/generator/satellite.py,sha256=_7RcuNmR1mjxEJWMDsjnzKUIqWxnGUn50XtjB7HmSPg,3661
|
14
14
|
maps4fs/generator/settings.py,sha256=9vbXISQrE-aDY7ATpvZ7LVJMqjfwa3-gNl-huI8XLO0,5666
|
15
|
-
maps4fs/generator/texture.py,sha256=
|
15
|
+
maps4fs/generator/texture.py,sha256=P4AJjedG98SFxrw4hBenw7_OgtkcI0TpE63fEffJ2eE,33761
|
16
16
|
maps4fs/generator/dtm/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
17
|
-
maps4fs/generator/dtm/dtm.py,sha256=
|
18
|
-
maps4fs/generator/dtm/
|
19
|
-
maps4fs/generator/dtm/
|
17
|
+
maps4fs/generator/dtm/dtm.py,sha256=T2h7eP5kQEWTGllI8ZxcGCDW4czZSoPBgOUS8a7_Ym8,19105
|
18
|
+
maps4fs/generator/dtm/nrw.py,sha256=lJYZBZB4n5egUlX2iA7AhLmMIRKB7i_LVxCTohWsSmw,4612
|
19
|
+
maps4fs/generator/dtm/srtm.py,sha256=RsvVa7ErajPwXoetG7mO_rldji9GR97HFaazH-PkdHw,4399
|
20
|
+
maps4fs/generator/dtm/usgs.py,sha256=fWFR_kO_uLVjAJAL43YFvBaHwsXWI-00jMlp23Ue7Wo,5450
|
20
21
|
maps4fs/toolbox/__init__.py,sha256=zZMLEkGzb4z0xql650gOtGSvcgX58DnJ2yN3vC2daRk,43
|
21
22
|
maps4fs/toolbox/background.py,sha256=9BXWNqs_n3HgqDiPztWylgYk_QM4YgBpe6_ZNQAWtSc,2154
|
22
23
|
maps4fs/toolbox/dem.py,sha256=z9IPFNmYbjiigb3t02ZenI3Mo8odd19c5MZbjDEovTo,3525
|
23
|
-
maps4fs-1.7.
|
24
|
-
maps4fs-1.7.
|
25
|
-
maps4fs-1.7.
|
26
|
-
maps4fs-1.7.
|
27
|
-
maps4fs-1.7.
|
24
|
+
maps4fs-1.7.3.dist-info/LICENSE.md,sha256=pTKD_oUexcn-yccFCTrMeLkZy0ifLRa-VNcDLqLZaIw,10749
|
25
|
+
maps4fs-1.7.3.dist-info/METADATA,sha256=3oaI3MI_V8Vf-qZi3r3bS1Qn-Y806sDHE7g1JqV9YDI,40240
|
26
|
+
maps4fs-1.7.3.dist-info/WHEEL,sha256=In9FTNxeP60KnTkGw7wk6mJPYd_dQSjEZmXdBdMCI-8,91
|
27
|
+
maps4fs-1.7.3.dist-info/top_level.txt,sha256=Ue9DSRlejRQRCaJueB0uLcKrWwsEq9zezfv5dI5mV1M,8
|
28
|
+
maps4fs-1.7.3.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|