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 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
@@ -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
- if not provider.is_base():
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
- def download_tile(self, output_path: str, **kwargs) -> bool:
220
- """Download a tile from the provider.
221
-
222
- Arguments:
223
- output_path (str): Path to save the downloaded tile.
250
+ @abstractmethod
251
+ def download_tiles(self) -> list[str]:
252
+ """Download tiles from the provider.
224
253
 
225
254
  Returns:
226
- bool: True if the tile was downloaded successfully, False otherwise.
255
+ list: List of paths to the downloaded tiles.
227
256
  """
228
- url = self.formatted_url(**kwargs)
229
- response = requests.get(url, stream=True, timeout=10)
230
- if response.status_code == 200:
231
- with open(output_path, "wb") as file:
232
- for chunk in response.iter_content(chunk_size=1024):
233
- file.write(chunk)
234
- return True
235
- return False
236
-
237
- def get_or_download_tile(self, output_path: str, **kwargs) -> str | None:
238
- """Get or download a tile from the provider.
257
+ raise NotImplementedError
239
258
 
240
- Arguments:
241
- output_path (str): Path to save the downloaded tile.
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
- str: Path to the downloaded tile or None if the tile not exists and was
245
- not downloaded.
265
+ np.ndarray: Numpy array of the tile.
246
266
  """
247
- if not os.path.exists(output_path):
248
- if not self.download_tile(output_path, **kwargs):
249
- return None
250
- return output_path
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
- def get_tile_parameters(self, *args, **kwargs) -> dict:
253
- """Get parameters for the tile, that will be used to format the URL.
254
- Must be implemented in subclasses.
296
+ Arguments:
297
+ data (np.ndarray): Elevation data.
255
298
 
256
299
  Returns:
257
- dict: Tile parameters to format the URL.
300
+ np.ndarray: Processed elevation data.
258
301
  """
259
- raise NotImplementedError
302
+ self.data_info = {}
303
+ self.add_numpy_params(data, "original")
260
304
 
261
- @abstractmethod
262
- def get_numpy(self) -> np.ndarray:
263
- """Get numpy array of the tile.
264
- Resulting array must be 16 bit (signed or unsigned) integer and it should be already
265
- windowed to the bounding box of ROI. It also must have only one channel.
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
- np.ndarray: Numpy array of the tile.
352
+ dict[str, int | str | float] | None: Information sequence for the component.
269
353
  """
270
- raise NotImplementedError
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 info_sequence(self) -> dict[str, int | str | float] | None:
314
- """Returns the information sequence for the component. Must be implemented in the child
315
- class. If the component does not have an information sequence, an empty dictionary must be
316
- returned.
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
- dict[str, int | str | float] | None: Information sequence for the component.
536
+ np.ndarray: Normalized DEM data.
320
537
  """
321
- return self.data_info
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