Skip to content

Commit

Permalink
SRTM Easy Mode
Browse files Browse the repository at this point in the history
* Expected maximum height for SRTM provider.

* Linter updates.

* Easy mode updates.

* Generation info for DEM.

* Updating height scale.

* Z scaling.

* Tests udate.

* Pylint updates.

* Linter updates.
  • Loading branch information
iwatkot authored Jan 5, 2025
1 parent 72b3e85 commit 321b1cf
Show file tree
Hide file tree
Showing 12 changed files with 269 additions and 35 deletions.
28 changes: 27 additions & 1 deletion maps4fs/generator/background.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,9 @@ def info_sequence(self) -> dict[str, str | float | int]:
"east": east,
"west": west,
}

dem_info_sequence = self.dem.info_sequence()
data["DEM"] = dem_info_sequence
return data # type: ignore

def qgis_sequence(self) -> None:
Expand Down Expand Up @@ -294,7 +297,7 @@ def plane_from_np(
mesh.apply_transform(rotation_matrix_z)

# if not include_zeros:
z_scaling_factor = 1 / self.map.dem_settings.multiplier
z_scaling_factor = self.get_z_scaling_factor()
self.logger.debug("Z scaling factor: %s", z_scaling_factor)
mesh.apply_scale([1 / resize_factor, 1 / resize_factor, z_scaling_factor])

Expand All @@ -309,6 +312,29 @@ def plane_from_np(
mesh.apply_scale([0.5, 0.5, 0.5])
self.mesh_to_stl(mesh)

def get_z_scaling_factor(self) -> float:
"""Calculates the scaling factor for the Z axis based on the map settings.
Returns:
float -- The scaling factor for the Z axis.
"""

scaling_factor = 1 / self.map.dem_settings.multiplier
self.logger.debug("Z scaling factor including DEM multiplier: %s", scaling_factor)

if self.map.shared_settings.height_scale_multiplier:
scaling_factor *= self.map.shared_settings.height_scale_multiplier
self.logger.debug(
"Z scaling factor including height scale multiplier: %s", scaling_factor
)
if self.map.shared_settings.mesh_z_scaling_factor:
scaling_factor *= 1 / self.map.shared_settings.mesh_z_scaling_factor
self.logger.debug(
"Z scaling factor including mesh z scaling factor: %s", scaling_factor
)

return scaling_factor

def mesh_to_stl(self, mesh: trimesh.Trimesh) -> None:
"""Converts the mesh to an STL file and saves it in the previews directory.
Uses powerful simplification to reduce the size of the file since it will be used
Expand Down
2 changes: 1 addition & 1 deletion maps4fs/generator/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ def __init__(
self.logger = logger
self.kwargs = kwargs

self.logger.info(
self.logger.debug(
"Component %s initialized. Map size: %s, map rotated size: %s", # type: ignore
self.__class__.__name__,
self.map_size,
Expand Down
15 changes: 15 additions & 0 deletions maps4fs/generator/dem.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""This module contains DEM class for processing Digital Elevation Model data."""

import os
from typing import Any

import cv2
import numpy as np
Expand Down Expand Up @@ -63,6 +64,7 @@ def preprocess(self) -> None:
size=self.map_rotated_size,
directory=self.temp_dir,
logger=self.logger,
map=self.map,
)

@property
Expand Down Expand Up @@ -280,3 +282,16 @@ def previews(self) -> list:
list: Empty list.
"""
return []

def info_sequence(self) -> dict[Any, Any] | None: # type: ignore
"""Returns the information sequence for the component. Must be implemented in the child
class. If the component does not have an information sequence, an empty dictionary must be
returned.
Returns:
dict[Any, Any]: The information sequence for the component.
"""
provider_info_sequence = self.dtm_provider.info_sequence()
if provider_info_sequence is None:
return {}
return provider_info_sequence
37 changes: 36 additions & 1 deletion maps4fs/generator/dtm/dtm.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from __future__ import annotations

import os
from typing import Type
from typing import TYPE_CHECKING, Type

import numpy as np
import osmnx as ox # type: ignore
Expand All @@ -15,6 +15,9 @@

from maps4fs.logger import Logger

if TYPE_CHECKING:
from maps4fs.generator.map import Map


class DTMProviderSettings(BaseModel):
"""Base class for DTM provider settings models."""
Expand Down Expand Up @@ -45,6 +48,7 @@ def __init__(
size: int,
directory: str,
logger: Logger,
map: Map | None = None, # pylint: disable=W0622
):
self._coordinates = coordinates
self._user_settings = user_settings
Expand All @@ -56,6 +60,27 @@ def __init__(
os.makedirs(self._tile_directory, exist_ok=True)

self.logger = logger
self.map = map

self._data_info: dict[str, int | str | float] | None = None

@property
def data_info(self) -> dict[str, int | str | float] | None:
"""Information about the DTM data.
Returns:
dict: Information about the DTM data.
"""
return self._data_info

@data_info.setter
def data_info(self, value: dict[str, int | str | float] | None) -> None:
"""Set information about the DTM data.
Arguments:
value (dict): Information about the DTM data.
"""
self._data_info = value

@property
def coordinates(self) -> tuple[float, float]:
Expand Down Expand Up @@ -260,3 +285,13 @@ def extract_roi(self, tile_path: str) -> np.ndarray:
raise ValueError("No data in the tile.")

return data

def info_sequence(self) -> dict[str, int | str | float] | None:
"""Returns the information sequence for the component. Must be implemented in the child
class. If the component does not have an information sequence, an empty dictionary must be
returned.
Returns:
dict[str, int | str | float] | None: Information sequence for the component.
"""
return self.data_info
154 changes: 147 additions & 7 deletions maps4fs/generator/dtm/srtm.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,22 @@
"""This module contains provider of Shuttle Radar Topography Mission (SRTM) 30m data."""

# Author: https://github.com/iwatkot

import gzip
import math
import os
import shutil

import numpy as np

from maps4fs.generator.dtm.dtm import DTMProvider
from maps4fs.generator.dtm.dtm import DTMProvider, DTMProviderSettings


class SRTM30ProviderSettings(DTMProviderSettings):
"""Settings for SRTM 30m provider."""

easy_mode: bool = True
power_factor: int = 0


class SRTM30Provider(DTMProvider):
Expand All @@ -24,19 +33,30 @@ class SRTM30Provider(DTMProvider):
_author = "[iwatkot](https://github.com/iwatkot)"

_instructions = (
"ℹ️ If you're a rookie in the Giants Editor check the **Apply the default multiplier** "
"checkbox. Otherwise, you can change the multiplier value in the DEM Settings. "
"If you will not apply the default multiplier and not change the value in the DEM "
"Settings, you'll have the DEM image with the original values as on Earth, which can "
"not be seen by eye and will lead to completely flat terrain. "
"ℹ️ If you don't know how to work with DEM data, it is recommended to use the "
"**Easy mode** option. It will automatically change the values in the image, so the "
"terrain will be visible in the Giants Editor. If you're an experienced modder, it's "
"recommended to disable this option and work with the DEM data in a usual way. \n"
"ℹ️ If the terrain height difference in the real world is bigger than 255 meters, "
"the [Height scale](https://github.com/iwatkot/maps4fs/blob/main/docs/dem.md#height-scale)"
" parameter in the **map.i3d** file will be changed automatically. \n"
"⚡ If the **Easy mode** option is disabled, you will probably get completely flat "
"terrain, unless you adjust the DEM Multiplier Setting or the Height scale parameter in "
"the Giants Editor. \n"
"💡 You can use the **Power factor** setting to make the difference between heights "
"bigger. Be extremely careful with this setting, and use only low values, otherwise your "
"terrain may be completely broken. \n"
)

_settings = SRTM30ProviderSettings

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.hgt_directory = os.path.join(self._tile_directory, "hgt")
self.gz_directory = os.path.join(self._tile_directory, "gz")
os.makedirs(self.hgt_directory, exist_ok=True)
os.makedirs(self.gz_directory, exist_ok=True)
self.data_info: dict[str, int | str | float] | None = None # type: ignore

def get_tile_parameters(self, *args, **kwargs) -> dict[str, str]:
"""Returns latitude band and tile name for SRTM tile from coordinates.
Expand Down Expand Up @@ -83,4 +103,124 @@ def get_numpy(self) -> np.ndarray:
with open(decompressed_tile_path, "wb") as f_out:
shutil.copyfileobj(f_in, f_out)

return self.extract_roi(decompressed_tile_path)
data = self.extract_roi(decompressed_tile_path)

self.data_info = {}
self.add_numpy_params(data, "original")

data = self.signed_to_unsigned(data)
self.add_numpy_params(data, "grounded")

original_deviation = int(self.data_info["original_deviation"])
in_game_maximum_height = 65535 // 255
if original_deviation > in_game_maximum_height:
suggested_height_scale_multiplier = (
original_deviation / in_game_maximum_height # type: ignore
)
suggested_height_scale_value = int(255 * suggested_height_scale_multiplier)
else:
suggested_height_scale_multiplier = 1
suggested_height_scale_value = 255

self.data_info["suggested_height_scale_multiplier"] = suggested_height_scale_multiplier
self.data_info["suggested_height_scale_value"] = suggested_height_scale_value

self.map.shared_settings.height_scale_multiplier = ( # type: ignore
suggested_height_scale_multiplier
)
self.map.shared_settings.height_scale_value = suggested_height_scale_value # type: ignore

if self.user_settings.easy_mode: # type: ignore
try:
data = self.normalize_dem(data)
self.add_numpy_params(data, "normalized")

normalized_deviation = self.data_info["normalized_deviation"]
z_scaling_factor = normalized_deviation / original_deviation # type: ignore
self.data_info["z_scaling_factor"] = z_scaling_factor

self.map.shared_settings.mesh_z_scaling_factor = z_scaling_factor # type: ignore
self.map.shared_settings.change_height_scale = True # type: ignore

except Exception as e: # pylint: disable=W0718
self.logger.error(
"Failed to normalize DEM data. Error: %s. Using original data.", e
)

return data

def add_numpy_params(
self,
data: np.ndarray,
prefix: str,
) -> None:
"""Add numpy array parameters to the data_info dictionary.
Arguments:
data (np.ndarray): Numpy array of the tile.
prefix (str): Prefix for the parameters.
"""
self.data_info[f"{prefix}_minimum_height"] = int(data.min()) # type: ignore
self.data_info[f"{prefix}_maximum_height"] = int(data.max()) # type: ignore
self.data_info[f"{prefix}_deviation"] = int(data.max() - data.min()) # type: ignore
self.data_info[f"{prefix}_unique_values"] = int(np.unique(data).size) # type: ignore

def signed_to_unsigned(self, data: np.ndarray, add_one: bool = True) -> np.ndarray:
"""Convert signed 16-bit integer to unsigned 16-bit integer.
Arguments:
data (np.ndarray): DEM data from SRTM file after cropping.
Returns:
np.ndarray: Unsigned DEM data.
"""
data = data - data.min()
if add_one:
data = data + 1
return data.astype(np.uint16)

def normalize_dem(self, data: np.ndarray) -> np.ndarray:
"""Normalize DEM data to 16-bit unsigned integer using max height from settings.
Arguments:
data (np.ndarray): DEM data from SRTM file after cropping.
Returns:
np.ndarray: Normalized DEM data.
"""
maximum_height = int(data.max())
minimum_height = int(data.min())
deviation = maximum_height - minimum_height
self.logger.debug(
"Maximum height: %s. Minimum height: %s. Deviation: %s.",
maximum_height,
minimum_height,
deviation,
)
self.logger.debug("Number of unique values in original DEM data: %s.", np.unique(data).size)

adjusted_maximum_height = maximum_height * 255
adjusted_maximum_height = min(adjusted_maximum_height, 65535)
scaling_factor = adjusted_maximum_height / maximum_height
self.logger.debug(
"Adjusted maximum height: %s. Scaling factor: %s.",
adjusted_maximum_height,
scaling_factor,
)

if self.user_settings.power_factor: # type: ignore
power_factor = 1 + self.user_settings.power_factor / 10 # type: ignore
self.logger.debug(
"Applying power factor: %s to the DEM data.",
power_factor,
)
data = np.power(data, power_factor).astype(np.uint16)

normalized_data = np.round(data * scaling_factor).astype(np.uint16)
self.logger.debug(
"Normalized data maximum height: %s. Minimum height: %s. Number of unique values: %s.",
normalized_data.max(),
normalized_data.min(),
np.unique(normalized_data).size,
)
return normalized_data
19 changes: 14 additions & 5 deletions maps4fs/generator/i3d.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@
from maps4fs.generator.component import Component
from maps4fs.generator.texture import Texture

DEFAULT_HEIGHT_SCALE = 2000
DISPLACEMENT_LAYER_SIZE_FOR_BIG_MAPS = 32768
DEFAULT_MAX_LOD_DISTANCE = 10000
DEFAULT_MAX_LOD_OCCLUDER_DISTANCE = 10000
Expand Down Expand Up @@ -81,6 +80,20 @@ def _update_i3d_file(self) -> None:

root = tree.getroot()
for map_elem in root.iter("Scene"):
for terrain_elem in map_elem.iter("TerrainTransformGroup"):
if self.map.shared_settings.change_height_scale:
suggested_height_scale = self.map.shared_settings.height_scale_value
if suggested_height_scale is not None and suggested_height_scale > 255:
new_height_scale = int(
self.map.shared_settings.height_scale_value # type: ignore
)
terrain_elem.set("heightScale", str(new_height_scale))
self.logger.info(
"heightScale attribute set to %s in TerrainTransformGroup element.",
new_height_scale,
)

self.logger.debug("TerrainTransformGroup element updated in I3D file.")
sun_elem = map_elem.find(".//Light[@name='sun']")

if sun_elem is not None:
Expand All @@ -97,10 +110,6 @@ def _update_i3d_file(self) -> None:
)

if self.map_size > 4096:
terrain_elem = root.find(".//TerrainTransformGroup")
if terrain_elem is None:
self.logger.warning("TerrainTransformGroup element not found in I3D file.")
return
displacement_layer = terrain_elem.find(".//DisplacementLayer") # pylint: disable=W0631

if displacement_layer is not None:
Expand Down
Loading

0 comments on commit 321b1cf

Please sign in to comment.