diff --git a/.streamlit/config.toml b/.streamlit/config.toml new file mode 100644 index 00000000..4bedf73a --- /dev/null +++ b/.streamlit/config.toml @@ -0,0 +1,4 @@ +# .streamlit/config.toml + +[server] +maxUploadSize = 500 \ No newline at end of file diff --git a/.vscode/tasks.json b/.vscode/tasks.json index d6033957..f9af9c13 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -4,7 +4,7 @@ { "label": "Run Streamlit", "type": "shell", - "command": "streamlit run webui/webui.py", + "command": "streamlit run webui/main.py", "group": "build", "presentation": { "reveal": "always", diff --git a/README.md b/README.md index 32fe2a94..9be0929e 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ Quick StartOverviewHow-To-Run • - FAQ
+ FAQ • + Modder Toolbox
Supported objectsGeneration infoTexture schema • @@ -41,6 +42,7 @@ 🚜 Supports Farming Simulator 22 and 25
🔷 Generates *.obj files for background terrain based on the real-world height map
📄 Generates scripts to download high-resolution satellite images from [QGIS](https://qgis.org/download/) in one click
+🧰 Modder Toolbox to help you with various of tasks 🆕


@@ -172,6 +174,16 @@ for active_component in map.generate(): The map will be saved in the `map_directory` directory. +## Modder Toolbox +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.
+ +![Modder Toolbox](https://github.com/user-attachments/assets/18f169e9-1a5b-474c-b488-6becfffadcea) + +### Tool categories +Tools are divided into categories, which are listed below. +#### Textures and DEM +- **GeoTIFF windowing** - allows you to upload your GeoTIFF file and select the region of interest to extract it from the image. + ## Supported objects The project is based on the [OpenStreetMap](https://www.openstreetmap.org/) data. So, refer to [this page](https://wiki.openstreetmap.org/wiki/Map_Features) to understand the list below. - "building": True @@ -350,9 +362,9 @@ If you're willing to create a background terrain, you will need: Blender, the Bl If you're afraid of this task, please don't be. It's really simple and I've prepaired detailed step-by-step instructions for you, you'll find them in the separate README files. Here are the steps you need to follow: -1. [Download high-resolution satellite images](tutorials/README_satellite_images.md). -2. [Prepare the i3d files](tutorials/README_i3d.md). -3. [Import the i3d files to Giants Editor](tutorials/README_giants_editor.md). +1. [Download high-resolution satellite images](docs/download_satellite_images.md). +2. [Prepare the i3d files](docs/create_background_terrain.md). +3. [Import the i3d files to Giants Editor](docs/import_to_giants_editor.md). ## Overview image The overview image is an image that is used as in-game map. No matter what the size of the map, this file is always `4096x4096 pixels`, while the region of your map is `2048x2048 pixels` in center of this file. The rest of the image is just here for nice view, but you still may add satellite pictures to this region.
@@ -392,7 +404,7 @@ You can also apply some advanced settings to the map generation process. Note th Here's the list of the advanced settings: -- DEM multiplier: the height of the map is multiplied by this value. So the DEM map is just a 16-bit grayscale image, which means that the maximum avaiable value there is 65535, while the actual difference between the deepest and the highest point on Earth is about 20 km. So, by default this value is set to 3. Just note that this setting mostly does not matter, because you can always adjust it in the Giants Editor, learn more about the [heightScale](https://www.farming-simulator.org/19/terrain-heightscale.php) parameter on the [PMC Farming Simulator](https://www.farming-simulator.org/) website. +- DEM multiplier: the height of the map is multiplied by this value. So the DEM map is just a 16-bit grayscale image, which means that the maximum avaiable value there is 65535, while the actual difference between the deepest and the highest point on Earth is about 20 km. Just note that this setting mostly does not matter, because you can always adjust it in the Giants Editor, learn more about the DEM file and the heightScale paramter in [docs](docs/dem.md). By default, it's set to 1. - DEM Blur radius: the radius of the Gaussian blur filter applied to the DEM map. By default, it's set to 21. This filter just makes the DEM map smoother, so the height transitions will be more natural. You can set it to 1 to disable the filter, but it will result as a Minecraft-like map. @@ -410,5 +422,5 @@ To create a basic map, you only need the Giants Editor. But if you want to creat 6. [AnyConv](https://anyconv.com/png-to-dds-converter/) - the online tool to convert the PNG images to the DDS format. You'll need this format for the textures, icons, overview and preview images. ## Bugs and feature requests -➡️ Please, before creating an issue or asking some questions, check the [FAQ](tutorials/FAQ.md) section.
+➡️ Please, before creating an issue or asking some questions, check the [FAQ](docs/FAQ.md) section.
If you find a bug or have an idea for a new feature, please create an issue [here](https://github.com/iwatkot/maps4fs/issues) or contact me directly on [Telegram](https://t.me/iwatkot) or on Discord: `iwatkot`. diff --git a/tutorials/FAQ.md b/docs/FAQ.md similarity index 55% rename from tutorials/FAQ.md rename to docs/FAQ.md index 0c09dc55..543bf561 100644 --- a/tutorials/FAQ.md +++ b/docs/FAQ.md @@ -8,13 +8,25 @@ First of all, you need to understand, that the project uses the data from the [O ### I can see the object on OSM, but it doesn't appear on the map, why? -The `maps4fs` tool DOES NOT add everything from OSM to the map. Instead of projecting everything, it works with a whitelist of objects that are allowed to be displayed. And you, actually, can check this list in the [Supported Objects](../README.md#supported-objects) section of the main README file.
-➡️ It's really easy to add a new object to the whitelist, so if you think that something should be displayed, contact me in [Discord](https://discord.gg/Sj5QKKyE42) or open an issue on GitHub. I will check the object and add it to the whitelist if it's correct. +The `maps4fs` tool DOES NOT add everything from OSM to the map. Instead of projecting everything, it works with a whitelist of objects that are allowed to be displayed. And you, actually, can check this list in the [Supported Objects](../README.md#supported-objects) section of the main README file. +It's really easy to add a new object to the whitelist, so if you think that something should be displayed, contact me in [Discord](https://discord.gg/Sj5QKKyE42) or open an issue on GitHub. I will check the object and add it to the whitelist if it's correct. ### There's no needed objects on OSM, what should I do? -The good news is that you can add them by yourself! The OpenStreetMap project is open for everyone, and you can add any object you want. Just go to the [OpenStreetMap](https://www.openstreetmap.org/) website, create an account, and start mapping. Ensure, that you're adding the correct objects with corresponding tags, and they will appear on the map, usually it taskes from 5 to 30 minutes.
-➡️ Please, while editing OSM, follow the [OSM Wiki](https://wiki.openstreetmap.org/wiki/Main_Page) and the [OSM Tags](https://wiki.openstreetmap.org/wiki/Map_Features) to add the correct objects. And also, respect the [OSM Guidelines](https://wiki.openstreetmap.org/wiki/Good_practice) and the community of this incredible project. Don't mess up with the data, and don't add anything that doesn't exist in the real world. It's just not cool. +The good news is that you can add them by yourself! The OpenStreetMap project is open for everyone, and you can add any object you want. Just go to the [OpenStreetMap](https://www.openstreetmap.org/) website, create an account, and start mapping. Ensure, that you're adding the correct objects with corresponding tags, and they will appear on the map, usually it taskes from 5 to 30 minutes. +Please, while editing OSM, follow the [OSM Wiki](https://wiki.openstreetmap.org/wiki/Main_Page) and the [OSM Tags](https://wiki.openstreetmap.org/wiki/Map_Features) to add the correct objects. And also, respect the [OSM Guidelines](https://wiki.openstreetmap.org/wiki/Good_practice) and the community of this incredible project. Don't mess up with the data, and don't add anything that doesn't exist in the real world. It's just not cool. + +### How can I download satellite images for the map? + +You can find the detailed tutorial [here](https://github.com/iwatkot/maps4fs/blob/main/docs/download_satellite_images.md). + +### How can I texture object and export it in the *.i3d format? + +You can find the detailed tutorial [here](https://github.com/iwatkot/maps4fs/blob/main/docs/create_background_terrain.md). + +### How can I import the *.i3d file to the map? + +You can find the detailed tutorial [here](https://github.com/iwatkot/maps4fs/blob/main/docs/import_to_giants_editor.md). If you think that some question should be added here, please, contact me in [Discord](https://discord.gg/Sj5QKKyE42) or open an issue on GitHub. Thank you! \ No newline at end of file diff --git a/tutorials/README_i3d.md b/docs/create_background_terrain.md similarity index 94% rename from tutorials/README_i3d.md rename to docs/create_background_terrain.md index 6726041c..1528b47f 100644 --- a/tutorials/README_i3d.md +++ b/docs/create_background_terrain.md @@ -1,8 +1,8 @@ ## How to create a background terrain -To create a background terrain for the Farming Simulator map, you need to open the `*.obj` files with the terrain, obtain the satellite images as described in the [Download high-resolution satellite images](README_satellite_images.md) tutorial to use them as textures and export your results to the `*.i3d` format.
+To create a background terrain for the Farming Simulator map, you need to open the `*.obj` files with the terrain, obtain the satellite images as described in the [Download high-resolution satellite images](download_satellite_images.md) tutorial to use them as textures and export your results to the `*.i3d` format.
-ℹ️ In this tutorials it's assumed that you have already generated the map and downloaded satellite images from the previous step: [Download high-resolution satellite images](README_satellite_images.md).
+ℹ️ In this tutorials it's assumed that you have already generated the map and downloaded satellite images from the previous step: [Download high-resolution satellite images](download_satellite_images.md).
Let's go straight to the deal: @@ -78,4 +78,4 @@ Now ensure that the object is selected [1], specify the path to the output file ![Export to i3d](https://github.com/user-attachments/assets/ad3913d7-a16e-47c0-a039-9f792e34ad4c) -Now we can go to the final section of the tutorial: [Import the i3d files to Giants Editor](README_giants_editor.md). Or you can go back to the previous step: [Download high-resolution satellite images](README_satellite_images.md). \ No newline at end of file +Now we can go to the final section of the tutorial: [Import the i3d files to Giants Editor](import_to_giants_editor.md). Or you can go back to the previous step: [Download high-resolution satellite images](download_satellite_images.md). \ No newline at end of file diff --git a/docs/dem.md b/docs/dem.md new file mode 100644 index 00000000..170ee355 --- /dev/null +++ b/docs/dem.md @@ -0,0 +1,17 @@ +## Digital Elevation Models (DEM) +DEM is used in Farming Simulator maps to define the terrain height. +Every hill, valley, and slope is defined by a DEM. While it may sounds complex, it's really just a 2D grid where each cell has a height value. +### File description +**Image size:** FS25 -> (map height + 1, map width + 1) FS22 -> (map height / 2 + 1, map width / 2 + 1) +**Channels:** 1 +**Data Type:** uint16 (unsigned 16-bit integer) +**File Format:** .png +**File Path:** FS25 -> `map_directory/data/dem.png` FS22 -> `map_directory/data/map_dem.png` +DEM image is a single channel unsigned 16-bit integer image, which means that each pixel can have an integer value between 0 and 2^16 (65535). So, if the image can have values from 0 to 65535, while the highest point on Earth is 8848 meters, how does it work? +### Height scale +And this, where the **heightScale** parameter comes in. It's a multiplier that converts the pixel value to it's in-game height. By default, in Giants maps, it's value set to 255, but if you're working with DEMs, which contains real-world height values, you should make it much higher. The selection of the actual value is up to you, you can play around with it to get the best result. +To set this value, you need to open the map.i3d file in Giants Editor, select the terrain on the **Scenegraph** tab, choose **Terrain** tab in the **Attributes** window, and set the **heightScale** parameter. After it you usually need to save the file and reload the map (**File** -> **Reload**). +### Units per pixel +In Farming Simulator 25 the size of the DEM image is usually the same as the map size but with an additional pixel in each dimension. For example, if the map size is 2048x2048, the DEM image size will be 2049x2049. But in Farming Simulator 22 the DEM image size is half of the map size. So, if the map size is 2048x2048, the DEM image size will be 1025x1025. +But actually, it can be changed using the **unitsPerPixel** parameter in the map.i3d file. It defines how many in-game units (meters) each pixel of the DEM image represents. So, in the FS25 by default, it's set to 1, which means that each pixel of the DEM image represents 1 meter in the game. But in FS22 it's set to 2, and that's why the DEM image size is half of the map size. +To set this value, you need to open the map.i3d file in Giants Editor, select the terrain on the **Scenegraph** tab, choose **Terrain** tab in the **Attributes** window, and set the **unitsPerPixel** parameter. Just a reminder, it should be an integer value. After it you usually need to save the file and reload the map (**File** -> **Reload**). \ No newline at end of file diff --git a/tutorials/README_satellite_images.md b/docs/download_satellite_images.md similarity index 93% rename from tutorials/README_satellite_images.md rename to docs/download_satellite_images.md index eae9189a..1deeb940 100644 --- a/tutorials/README_satellite_images.md +++ b/docs/download_satellite_images.md @@ -103,6 +103,20 @@ layers = [ ``` As a result of saving those `.tif` files, you'll get one image with the exact bounds and another one with the margin around it. In case you want manually adjust the bounds, you can use the image with the margin. +### Approach 1: Using maps4fs Toolbox: GeoTIFF windowing + +It's a recommended approach. + +2. Navigate to the `🧰 Modder Toolbox` -> `🖼️ Textures and DEM` -> `🪟 GeoTIFF windowing` and upload your tiff image (with margin). + +3. Enter coordinates of the center point of the map. It's recommended to paste it EXACTLY the same as in the `generation_info.json` and the size of the map (2048, 4096 and so on). Click on the `Extract ROI` button. + +![GeoTIFF windowing](https://github.com/user-attachments/assets/2e63345b-58b1-4d06-8c87-0f7e655a6413) + +4. Now you can download windowed image, that will be almost perfectly aligned with the map. But if you want to make it perfectly aligned, you can continue with the second approach. + +### Approach 2: Manual adjustment in image editor + 2. Create a new image in Photoshop or any other image editor, which allows working with layers, put some of your texture files in center of it. For example for map of size 4096 x 4096 pixels, you need to create an image of size 8192 x 8192 pixels, and you need to put the texture in the center of it. 3. Now add there your satellite images with margins, lower the opacity of this layer and try to manually adjust it. Please note, that Earth is not flat, so it WONT be just scale and move, you also need to rotate it a bit and maybe skew it. The recommended approach is to use the `Free Transform` tool in Photoshop and just move the corners until it fits the map. @@ -190,4 +204,4 @@ You should see the square with a hole in the center, where your map is located. -Go to the next section of the tutorial: [Prepare the i3d files](README_i3d.md). \ No newline at end of file +Go to the next section of the tutorial: [Prepare the i3d files](create_background_terrain.md). \ No newline at end of file diff --git a/tutorials/README_giants_editor.md b/docs/import_to_giants_editor.md similarity index 79% rename from tutorials/README_giants_editor.md rename to docs/import_to_giants_editor.md index 48be1b04..a0bbe946 100644 --- a/tutorials/README_giants_editor.md +++ b/docs/import_to_giants_editor.md @@ -2,7 +2,7 @@ So, at this point, you should have the `*.i3d` files with the background terrain. Now, let's import them into the Giants Editor. -ℹ️ In this tutorials it's assumed that you have already generated the map, downloaded satellite images using this tutorial: [Download high-resolution satellite images](README_satellite_images.md), and created a background terrain as described in the previous tutorial: [Prepare the i3d files](README_i3d.md). +ℹ️ In this tutorials it's assumed that you have already generated the map, downloaded satellite images using this tutorial: [Download high-resolution satellite images](download_satellite_images.md), and created a background terrain as described in the previous tutorial: [Prepare the i3d files](create_background_terrain.md). Here's what you need to do: @@ -10,7 +10,7 @@ Here's what you need to do: 2. Open the main map file, for example for the Farming Simulator 25 it's a `map/map.i3d` file. -3. Click on the `File` menu and select `Import...` select the `*.i3d` file with the background terrain. Note, that aftet the import, the terrain will have no textures, so don't worry, it's ok. +3. Click on the `File` menu and select `Import...` select the `*.i3d` file with the background terrain. ![Import](https://github.com/user-attachments/assets/32145805-6583-4147-ac04-4c69d041b554) @@ -18,7 +18,8 @@ Here's what you need to do: ![Position](https://github.com/user-attachments/assets/8202b2f5-2286-4213-8785-c3779e9ad88a) -5. Open the `Material Editing` panel (if it's not visible click on the `Window` menu and select `Material Editing`) and ensure that you've selected the correct object in the `Scenegraph` panel. Find the `Albedo map` and click on the `...` button to select the texture for the terrain. +5. If after the import you can't see the texture on the terrain, you need to add the texture manually. +Open the `Material Editing` panel (if it's not visible click on the `Window` menu and select `Material Editing`) and ensure that you've selected the correct object in the `Scenegraph` panel. Find the `Albedo map` and click on the `...` button to select the texture for the terrain. ![Albedo map](https://github.com/user-attachments/assets/20a197cd-dadf-4e61-8ad2-c6752d60fb17) @@ -37,6 +38,6 @@ Here's what you need to do: So, we'are done here.
ℹ️ Please note, that is almost no way to align all background terrain with map perfectly without editing them in the 3D editor, Blender for example. You can find a lot of tutorials on YouTube on how to do it, this won't be covered here. Or you can just leave it as is and find the best possible position for the terrain, and maybe hiding the edges with some objects. It's up to you.
-If you want, you can go back to the previous step: [Prepare the i3d files](README_i3d.md).
+If you want, you can go back to the previous step: [Prepare the i3d files](create_background_terrain.md).
Happy mapping! 🚜🌾 \ No newline at end of file diff --git a/maps4fs/toolbox/__init__.py b/maps4fs/toolbox/__init__.py new file mode 100644 index 00000000..7cac7701 --- /dev/null +++ b/maps4fs/toolbox/__init__.py @@ -0,0 +1 @@ +# pylint: disable=missing-module-docstring diff --git a/maps4fs/toolbox/dem.py b/maps4fs/toolbox/dem.py new file mode 100644 index 00000000..fc339f05 --- /dev/null +++ b/maps4fs/toolbox/dem.py @@ -0,0 +1,112 @@ +"""This module contains functions for working with Digital Elevation Models (DEMs).""" + +import os + +import rasterio # type: ignore +from pyproj import Transformer +from rasterio.io import DatasetReader # type: ignore +from rasterio.windows import from_bounds # type: ignore + + +def read_geo_tiff(file_path: str) -> DatasetReader: + """Read a GeoTIFF file and return the DatasetReader object. + + Args: + file_path (str): The path to the GeoTIFF file. + + Raises: + FileNotFoundError: If the file is not found. + RuntimeError: If there is an error reading the file. + + Returns: + DatasetReader: The DatasetReader object for the GeoTIFF file. + """ + if not os.path.isfile(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + try: + src = rasterio.open(file_path) + except Exception as e: + raise RuntimeError(f"Error reading file: {file_path}") from e + + if not src.bounds or not src.crs: + raise RuntimeError( + f"Can not read bounds or CRS from file: {file_path}. " + f"Bounds: {src.bounds}, CRS: {src.crs}" + ) + + return src + + +def get_geo_tiff_bbox( + src: DatasetReader, dst_crs: str | None = "EPSG:4326" +) -> tuple[float, float, float, float]: + """Return the bounding box of a GeoTIFF file in the destination CRS. + + Args: + src (DatasetReader): The DatasetReader object for the GeoTIFF file. + dst_crs (str, optional): The destination CRS. Defaults to "EPSG:4326". + + Returns: + tuple[float, float, float, float]: The bounding box in the destination CRS + as (north, south, east, west). + """ + left, bottom, right, top = src.bounds + + transformer = Transformer.from_crs(src.crs, dst_crs, always_xy=True) + + east, north = transformer.transform(left, top) + west, south = transformer.transform(right, bottom) + + return north, south, east, west + + +# pylint: disable=R0914 +def extract_roi(file_path: str, bbox: tuple[float, float, float, float]) -> str: + """Extract a region of interest (ROI) from a GeoTIFF file and save it as a new file. + + Args: + file_path (str): The path to the GeoTIFF file. + bbox (tuple[float, float, float, float]): The bounding box of the region of interest + as (north, south, east, west). + + Raises: + RuntimeError: If there is no data in the selected region. + + Returns: + str: The path to the new GeoTIFF file containing the extracted ROI. + """ + with rasterio.open(file_path) as src: + transformer = Transformer.from_crs("EPSG:4326", src.crs, always_xy=True) + north, south, east, west = bbox + + left, bottom = transformer.transform(west, south) + right, top = transformer.transform(east, north) + + window = from_bounds(left, bottom, right, top, src.transform) + data = src.read(window=window) + + if not data.size > 0: + raise RuntimeError("No data in the selected region.") + + base_name = os.path.basename(file_path).split(".")[0] + dir_name = os.path.dirname(file_path) + + output_name = f"{base_name}_{north}_{south}_{east}_{west}.tif" + + output_path = os.path.join(dir_name, output_name) + + with rasterio.open( + output_path, + "w", + driver="GTiff", + height=data.shape[1], + width=data.shape[2], + count=data.shape[0], + dtype=data.dtype, + crs=src.crs, + transform=src.window_transform(window), + ) as dst: + dst.write(data) + + return output_path diff --git a/pyproject.toml b/pyproject.toml index 9459501d..1aae8849 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "maps4fs" -version = "0.9.7" +version = "0.9.8" description = "Generate map templates for Farming Simulator from real places." authors = [{name = "iwatkot", email = "iwatkot@gmail.com"}] license = {text = "MIT License"} @@ -34,4 +34,4 @@ Repository = "https://github.com/iwatkot/maps4fs" [tool.setuptools.packages.find] where = ["."] include = ["maps4fs*"] -exclude = ["dev*", "bot*", "*data", "*docker", "*webui", "*tutorials"] +exclude = ["dev*", "bot*", "*data", "*docker", "*webui", "*docs"] diff --git a/webui/config.py b/webui/config.py index 43cd94e7..62628828 100644 --- a/webui/config.py +++ b/webui/config.py @@ -4,13 +4,27 @@ ARCHIVES_DIRECTORY = os.path.join(WORKING_DIRECTORY, "archives") MAPS_DIRECTORY = os.path.join(WORKING_DIRECTORY, "maps") OSMPS_DIRECTORY = os.path.join(WORKING_DIRECTORY, "osmps") +TEMP_DIRECTORY = os.path.join(WORKING_DIRECTORY, "temp") +INPUT_DIRECTORY = os.path.join(TEMP_DIRECTORY, "input") os.makedirs(ARCHIVES_DIRECTORY, exist_ok=True) os.makedirs(MAPS_DIRECTORY, exist_ok=True) os.makedirs(OSMPS_DIRECTORY, exist_ok=True) +os.makedirs(INPUT_DIRECTORY, exist_ok=True) + STREAMLIT_COMMUNITY_KEY = "HOSTNAME" STREAMLIT_COMMUNITY_VALUE = "streamlit" +DOCS_DIRECTORY = os.path.join(WORKING_DIRECTORY, "docs") +MD_FILES = {"⛰️ DEM": "dem.md"} +FAQ_MD = os.path.join(DOCS_DIRECTORY, "FAQ.md") + + +def get_mds() -> dict[str, str]: + return { + md_file: os.path.join(DOCS_DIRECTORY, filename) for md_file, filename in MD_FILES.items() + } + def is_on_community_server() -> bool: """Check if the script is running on the Streamlit Community server. diff --git a/webui/webui.py b/webui/generator.py similarity index 77% rename from webui/webui.py rename to webui/generator.py index 3d6e4ba1..4926b841 100644 --- a/webui/webui.py +++ b/webui/generator.py @@ -7,6 +7,7 @@ import streamlit.components.v1 as components from PIL import Image from streamlit_stl import stl_from_file +from templates import Messages import maps4fs as mfs from maps4fs.generator.dem import ( @@ -19,7 +20,7 @@ DEFAULT_LON = 20.237433441210115 -class Maps4FS: +class GeneratorUI: """Main class for the Maps4FS web interface. Attributes: @@ -47,8 +48,6 @@ def __init__(self): self.community = config.is_on_community_server() self.logger.info("The application launched on the community server: %s", self.community) - st.set_page_config(page_title="Maps4FS", page_icon="🚜", layout="wide") - self.left_column, self.right_column = st.columns(2, gap="large") if "generated" not in st.session_state: @@ -98,7 +97,8 @@ def map_preview(self) -> None: "Generating map preview for lat=%s, lon=%s, map_size=%s", lat, lon, map_size ) - html_file = osmp.get_preview(lat, lon, map_size) + bbox = osmp.get_bbox((lat, lon), map_size) + html_file = osmp.get_preview([bbox]) with self.html_preview_container: components.html(open(html_file).read(), height=400) @@ -114,35 +114,11 @@ def add_left_widgets(self) -> None: """Add widgets to the left column.""" self.logger.info("Adding widgets to the left column...") - st.title("Maps4FS") - st.write( - "Generate map templates for Farming Simulator from real places. \n" - "💬 Join our [Discord server](https://discord.gg/Sj5QKKyE42) to get help, share your " - "maps, or just chat. \n" - "🤗 If you like the project, consider supporting it on [Buy Me a Coffee](https://www.buymeacoffee.com/iwatkot0)." - ) - + st.title(Messages.TITLE) + st.write(Messages.MAIN_PAGE_DESCRIPTION) if self.community: - st.warning( - "🚜 Hey, farmer! \n" - "Do you know what **Docker** is? If yes, please consider running the application " - "locally. \n" - "On StreamLit community hosting the sizes of generated maps are limited " - "to a size of maximum 4096x4096 meters, while locally you only limited by " - "your hardware. \n" - "Learn more about the Docker version in the repo's " - "[README](https://github.com/iwatkot/maps4fs?tab=readme-ov-file#option-2-docker-version). \n" - "Also, if you are familiar with Python, you can use the " - "[maps4fs](https://pypi.org/project/maps4fs/) package to generate maps locally." - ) - - st.info( - "ℹ️ When opening the map first time in the Giants Editor, select the **terrain** object, " - "open the **Terrain** tab in the **Attributes** window, scroll down to the end " - "and press the **Reload material** button. \n" - "Otherwise you may (and will) face some glitches." - ) - + st.warning(Messages.MAIN_PAGE_COMMUNITY_WARNING) + st.info(Messages.TERRAIN_RELOAD) st.markdown("---") # Game selection (FS22 or FS25). @@ -202,20 +178,12 @@ def add_left_widgets(self) -> None: "💡 If you run the tool locally, you can generate larger maps, even with the custom size. \n" ) - st.info( - "ℹ️ Remember to adjust the ***heightScale*** parameter in the Giants Editor to a value " - "that suits your map. Learn more about it in repo's " - "[README](https://github.com/iwatkot/maps4fs)." - ) + st.info(Messages.HEIGHT_SCALE_INFO) self.auto_process = st.checkbox("Use auto preset", value=True, key="auto_process") if self.auto_process: self.logger.info("Auto preset is enabled.") - st.info( - "Auto preset will automatically apply different algorithms to make terrain more " - "realistic. It's recommended for most cases. If you want to have more control over the " - "terrain generation, you can disable this option and change the advanced settings." - ) + st.info(Messages.AUTO_PRESET_INFO) self.multiplier_input = DEFAULT_MULTIPLIER self.blur_radius_input = DEFAULT_BLUR_RADIUS @@ -224,14 +192,8 @@ def add_left_widgets(self) -> None: if not self.auto_process: self.logger.info("Auto preset is disabled.") - st.info( - "Auto preset is disabled. In this case you probably receive a full black DEM " - "image file. But it is NOT EMPTY. Dem image value range is from 0 to 65535, " - "while on Earth the highest point is 8848 meters. So, unless you are not " - "working with map for Everest, you probably can't see anything on the DEM image " - "by eye, because it is too dark. You can use the " - "multiplier option from Advanced settings to make the terrain more pronounced." - ) + st.info(Messages.AUTO_PRESET_DISABLED) + # Add checkbox for advanced settings. st.write("Advanced settings (do not change if you are not sure):") self.advanced_settings = st.checkbox("Show advanced settings", key="advanced_settings") @@ -246,12 +208,8 @@ def add_left_widgets(self) -> None: ) # Show multiplier and blur radius inputs. st.write("[DEM] Enter multiplier for the elevation map:") - st.write( - "This multiplier can be used to make the terrain more pronounced. " - "By default the DEM file will be exact copy of the real terrain. " - "If you want to make it more steep, you can increase this value. " - "Or make it smaller to make the terrain more flat." - ) + st.write(Messages.DEM_MULTIPLIER_INFO) + self.multiplier_input = st.number_input( "Multiplier", value=DEFAULT_MULTIPLIER, @@ -263,12 +221,8 @@ def add_left_widgets(self) -> None: ) st.write("[DEM] Enter blur radius for the elevation map:") - st.write( - "This value is used to blur the elevation map. Without blurring the terrain " - "may look too sharp and unrealistic. By default the blur radius is set to 21 " - "which corresponds to a 21x21 pixel kernel. You can increase this value to make " - "the terrain more smooth. Or make it smaller to make the terrain more sharp." - ) + st.write(Messages.DEM_BLUR_RADIUS_INFO) + self.blur_radius_input = st.number_input( "Blur Radius", value=DEFAULT_BLUR_RADIUS, @@ -280,12 +234,7 @@ def add_left_widgets(self) -> None: ) st.write("[DEM] Enter the plateau height (which will be added to the whole map):") - st.write( - "This value is used to make the whole map higher or lower. " - "This value will be added to each pixel of the DEM image, making it higher." - "It can be useful if you're working on a plain area and need to add some " - "negative height (to make rivers, for example)." - ) + st.write(Messages.DEM_PLATEAU_INFO) self.plateau_height_input = st.number_input( "Plateau Height", value=0, @@ -476,6 +425,3 @@ def show_preview(self, mp: mfs.Map) -> None: ) except Exception: continue - - -ui = Maps4FS() diff --git a/webui/main.py b/webui/main.py new file mode 100644 index 00000000..ddc8c2d3 --- /dev/null +++ b/webui/main.py @@ -0,0 +1,36 @@ +import streamlit as st +from config import FAQ_MD, get_mds +from generator import GeneratorUI +from templates import Messages +from toolbox import ToolboxUI + + +class WebUI: + def __init__(self): + st.set_page_config(page_title="maps4FS", page_icon="🚜", layout="wide") + generator_tab, toolbox_tab, knowledge_tab, faq_tab = st.tabs( + ["🗺️ Map Generator", "🧰 Modder Toolbox", "📖 Knowledge base", "📝 FAQ"] + ) + + with generator_tab: + self.generator = GeneratorUI() + + with toolbox_tab: + self.toolbox = ToolboxUI() + + with knowledge_tab: + st.title("Knowledge base") + st.write(Messages.KNOWLEDGE_INFO) + mds = get_mds() + + tabs = st.tabs(list(mds.keys())) + + for tab, md_path in zip(tabs, mds.values()): + with tab: + st.write(open(md_path, "r").read()) + + with faq_tab: + st.write(open(FAQ_MD, "r").read()) + + +WebUI() diff --git a/webui/osmp.py b/webui/osmp.py index 0165d2a2..b089ff88 100644 --- a/webui/osmp.py +++ b/webui/osmp.py @@ -1,72 +1,86 @@ import os +import random import config import folium import osmnx as ox -def get_preview(center_lat: float, center_lon: float, size_meters: int) -> str: - """Generate an HTML file with OpenStreetMap data centered at the given point and size in meters. +def get_preview(bboxes: list[tuple[float, float, float, float]]) -> str: + save_path = get_save_path(bboxes) + # if os.path.isfile(save_path): + # return save_path - Arguments: - center_lat (float): Latitude of the central point. - center_lon (float): Longitude of the central point. - size_meters (int): Width of the bounding box in meters. - output_file (str): Path to the output HTML file. - - Returns: - str: Path to the HTML file where the OpenStreetMap data is saved. - """ - save_path = get_save_path(center_lat, center_lon, size_meters) - if os.path.isfile(save_path): - return save_path - # Calculate the bounding box - center = (center_lat, center_lon) + m = folium.Map(zoom_control=False) - north, south, east, west = ox.utils_geo.bbox_from_point( - center, size_meters / 2, project_utm=False - ) + for bbox in bboxes: + center = get_center(bbox) + north, south, east, west = bbox + color = get_random_color() + folium.CircleMarker(center, radius=1, color=color, fill=True).add_to(m) - # Create a map centered at the given point - m = folium.Map(location=[center_lat, center_lon], max_bounds=True) + folium.Rectangle( + bounds=[[south, west], [north, east]], + color=color, + fill=True, + fill_opacity=0.1, + fill_color=color, + ).add_to(m) - # Draw the bounding box - folium.Rectangle( - bounds=[[south, west], [north, east]], color="blue", fill=True, fill_opacity=0.2 - ).add_to(m) + folium.ClickForMarker("${lat}, ${lng}").add_to(m) + # Fit bounds to the last bbox in the list. m.fit_bounds([[south, west], [north, east]]) - # Save the map as an HTML file m.save(save_path) return save_path -def get_save_path(lat: float, lon: float, size_meters: int) -> str: +def get_random_color() -> str: + return "#{:06x}".format(random.randint(0, 0xFFFFFF)) + + +def get_center(bbox: tuple[float, float, float, float]) -> tuple[float, float]: + north, south, east, west = bbox + return (north + south) / 2, (east + west) / 2 + + +def get_bbox(center: tuple[float, float], size_meters: int) -> tuple[float, float, float, float]: + center_lat, center_lon = center + north, south, east, west = ox.utils_geo.bbox_from_point( + (center_lat, center_lon), size_meters / 2, project_utm=False + ) + return north, south, east, west + + +def get_save_path(bboxes: list[tuple[float, float, float, float]]) -> str: """Return the path to the HTML file where the OpenStreetMap data is saved. Arguments: lat (float): Latitude of the central point. lon (float): Longitude of the central point. size_meters (int): Width of the bounding box in meters. + postfix (str): Optional postfix to add to the filename. Returns: str: Path to the HTML file. """ + file_names = [format_coordinates(bbox) for bbox in bboxes] + filename = "_".join(file_names) + ".html" return os.path.join( config.OSMPS_DIRECTORY, - f"{format_coordinates(lat, lon)}_{size_meters}.html", + filename, ) -def format_coordinates(lat: float, lon: float) -> str: +def format_coordinates(bbox: tuple[float, float, float, float]) -> str: """Return a string representation of the coordinates. Arguments: - lat (float): Latitude. - lon (float): Longitude. + bbox (tuple[float, float, float, float]): The bounding box coordinates. Returns: str: String representation of the coordinates. """ - return f"{lat:.6f}_{lon:.6f}" + # return f"{lat:.6f}_{lon:.6f}" + return "_".join(map(str, bbox)) diff --git a/webui/templates.py b/webui/templates.py new file mode 100644 index 00000000..a95f82ab --- /dev/null +++ b/webui/templates.py @@ -0,0 +1,70 @@ +class Messages: + TITLE = "maps4FS" + MAIN_PAGE_DESCRIPTION = ( + "Generate map templates for Farming Simulator from real places. \n" + "💬 Join our [Discord server](https://discord.gg/Sj5QKKyE42) to get help, share your " + "maps, or just chat. \n" + "🤗 If you like the project, consider supporting it on [Buy Me a Coffee](https://www.buymeacoffee.com/iwatkot0)." + ) + MAIN_PAGE_COMMUNITY_WARNING = ( + "🚜 Hey, farmer! \n" + "Do you know what **Docker** is? If yes, please consider running the application " + "locally. \n" + "On StreamLit community hosting the sizes of generated maps are limited " + "to a size of maximum 4096x4096 meters, while locally you only limited by " + "your hardware. \n" + "Learn more about the Docker version in the repo's " + "[README](https://github.com/iwatkot/maps4fs?tab=readme-ov-file#option-2-docker-version). \n" + "Also, if you are familiar with Python, you can use the " + "[maps4fs](https://pypi.org/project/maps4fs/) package to generate maps locally." + ) + TERRAIN_RELOAD = ( + "ℹ️ When opening the map first time in the Giants Editor, select the **terrain** object, " + "open the **Terrain** tab in the **Attributes** window, scroll down to the end " + "and press the **Reload material** button. \n" + "Otherwise you may (and will) face some glitches." + ) + HEIGHT_SCALE_INFO = ( + "ℹ️ Remember to adjust the ***heightScale*** parameter in the Giants Editor to a value " + "that suits your map. Learn more about it in repo's " + "[README](https://github.com/iwatkot/maps4fs?tab=readme-ov-file#For-advanced-users)." + ) + AUTO_PRESET_INFO = ( + "Auto preset will automatically apply different algorithms to make terrain more " + "realistic. It's recommended for most cases. If you want to have more control over the " + "terrain generation, you can disable this option and change the advanced settings." + ) + AUTO_PRESET_DISABLED = ( + "Auto preset is disabled. In this case you probably receive a full black DEM " + "image file. But it is NOT EMPTY. Dem image value range is from 0 to 65535, " + "while on Earth the highest point is 8848 meters. So, unless you are not " + "working with map for Everest, you probably can't see anything on the DEM image " + "by eye, because it is too dark. You can use the " + "multiplier option from Advanced settings to make the terrain more pronounced." + ) + DEM_MULTIPLIER_INFO = ( + "This multiplier can be used to make the terrain more pronounced. " + "By default the DEM file will be exact copy of the real terrain. " + "If you want to make it more steep, you can increase this value. " + "Or make it smaller to make the terrain more flat." + ) + DEM_BLUR_RADIUS_INFO = ( + "This value is used to blur the elevation map. Without blurring the terrain " + "may look too sharp and unrealistic. By default the blur radius is set to 21 " + "which corresponds to a 21x21 pixel kernel. You can increase this value to make " + "the terrain more smooth. Or make it smaller to make the terrain more sharp." + ) + DEM_PLATEAU_INFO = ( + "This value is used to make the whole map higher or lower. " + "This value will be added to each pixel of the DEM image, making it higher." + "It can be useful if you're working on a plain area and need to add some " + "negative height (to make rivers, for example)." + ) + TOOLBOX_INFO = ( + "This section contains different tools that can be helpful for modders, grouped by " + "the component of the map they are related to. \n" + ) + KNOWLEDGE_INFO = ( + "Here you can find some useful information about the different aspects of map modding." + ) + FAQ_INFO = "Here you can find answers to the most frequently asked questions." diff --git a/webui/toolbox.py b/webui/toolbox.py new file mode 100644 index 00000000..6869598f --- /dev/null +++ b/webui/toolbox.py @@ -0,0 +1,15 @@ +import streamlit as st +from templates import Messages +from tools.section import Section + + +class ToolboxUI: + def __init__(self): + st.write(Messages.TOOLBOX_INFO) + + sections = Section.all() + tabs = st.tabs([section.title for section in sections]) + + for tab, section in zip(tabs, sections): + with tab: + section.add() diff --git a/webui/tools/__init__.py b/webui/tools/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/webui/tools/dem.py b/webui/tools/dem.py new file mode 100644 index 00000000..e5d6d61c --- /dev/null +++ b/webui/tools/dem.py @@ -0,0 +1,147 @@ +import os + +import streamlit as st +import streamlit.components.v1 as components +from config import INPUT_DIRECTORY +from osmp import get_bbox, get_center, get_preview +from tools.tool import Tool + +from maps4fs.toolbox.dem import extract_roi, get_geo_tiff_bbox, read_geo_tiff + +DEFAULT_SIZE = 2048 +COMMUNITY_SIZE_LIMIT = 200 + + +class GeoTIFFWindowingTool(Tool): + title = "GeoTIFF Windowing" + description = "Extract region of interest from GeoTIFF file using given center point and size." + icon = "🪟" + + save_path = None + full_bbox = None + full_bbox_center = None + download_path = None + + def content(self): + if "windowed" not in st.session_state: + st.session_state.windowed = False + uploaded_file = st.file_uploader("Upload a GeoTIFF file", type=["tif", "tiff"]) + self.left_column, self.right_column = st.columns(2) + with self.right_column: + self.html_preview_container = st.empty() + + if uploaded_file is not None: + if not uploaded_file.name.lower().endswith((".tif", ".tiff")): + st.error("Please upload correct GeoTIFF file.") + return + + size_in_mb = round(uploaded_file.size / 1024 / 1024, 2) + + if True and size_in_mb > COMMUNITY_SIZE_LIMIT: + st.error( + f"The application is launched on Streamlit community server " + f"and file exceeds the size limit of {COMMUNITY_SIZE_LIMIT} MB. \n" + f"Please run the application locally to process larger files." + "Learn more about the Docker version in the repo's " + "[README](https://github.com/iwatkot/maps4fs?tab=readme-ov-file#option-2-docker-version)." + ) + return + + self.save_path = self.get_save_path(uploaded_file.name) + with open(self.save_path, "wb") as f: + f.write(uploaded_file.read()) + st.session_state.uploaded = True + + with self.left_column: + self.read_geo_tiff(self.save_path) + st.write("Enter latitude and longitude of the center point of the ROI:") + + full_center_lat, full_center_lon = self.full_bbox_center + + self.lat_lon_input = st.text_input( + "Latitude and Longitude", + f"{full_center_lat}, {full_center_lon}", + label_visibility="collapsed", + on_change=self.get_preview, + ) + + st.write("Enter the size of the ROI in meters:") + self.size_input = st.number_input( + "Size", + value=DEFAULT_SIZE, + label_visibility="collapsed", + on_change=self.get_preview, + ) + + self.get_preview() + + if st.button("Extract ROI", icon="▶️"): + self.window() + + if st.session_state.windowed: + with open(self.download_path, "rb") as f: + st.download_button( + label="Download", + data=f, + file_name=f"{self.download_path.split('/')[-1]}", + mime="application/zip", + icon="📥", + ) + + st.session_state.windowed = False + + def window(self): + try: + roi_center = self.lat_lon + except ValueError: + st.error("Please enter the latitude and longitude in the correct format.") + return + + roi_size = self.size_input + roi_bbox = get_bbox(roi_center, roi_size) + self.download_path = extract_roi(self.save_path, roi_bbox) + st.session_state.windowed = True + + def read_geo_tiff(self, file_path: str) -> None: + src = read_geo_tiff(file_path) + + st.write("Original CRS:", src.crs) + st.write("Bounds (original CRS):", src.bounds) + + left, bottom, right, top = src.bounds + st.write("Height (original CRS):", top - bottom) + st.write("Width (original CRS):", right - left) + + self.full_bbox = get_geo_tiff_bbox(src) + self.full_bbox_center = get_center(self.full_bbox) + + st.write("Bounding box (north, south, east, west):", self.full_bbox) + st.write("Center (latitude, longitude):", self.full_bbox_center) + + def get_save_path(self, file_name: str) -> str: + return os.path.join(INPUT_DIRECTORY, file_name) + + def get_preview(self): + try: + roi_center = self.lat_lon + except ValueError: + st.error("Please enter the latitude and longitude in the correct format.") + return + + roi_size = self.size_input + roi_bbox = get_bbox(roi_center, roi_size) + bboxes = [roi_bbox, self.full_bbox] + + html_file = get_preview(bboxes) + + with self.html_preview_container: + components.html(open(html_file).read(), height=600) + + @property + def lat_lon(self) -> tuple[float, float]: + """Get the latitude and longitude of the center point of the map. + + Returns: + tuple[float, float]: The latitude and longitude of the center point of the map. + """ + return tuple(map(float, self.lat_lon_input.split(","))) diff --git a/webui/tools/section.py b/webui/tools/section.py new file mode 100644 index 00000000..4f0a65b3 --- /dev/null +++ b/webui/tools/section.py @@ -0,0 +1,25 @@ +from typing import Type + +from tools.dem import GeoTIFFWindowingTool +from tools.tool import Tool + + +class Section: + title: str + description: str + tools: list[Type[Tool]] + + @classmethod + def all(cls): + return cls.__subclasses__() + + @classmethod + def add(cls): + for tool in cls.tools: + tool() + + +class TexturesAndDEM(Section): + title = "🖼️ Textures and DEM" + description = "Tools to work with textures and digital elevation models." + tools = [GeoTIFFWindowingTool] diff --git a/webui/tools/tool.py b/webui/tools/tool.py new file mode 100644 index 00000000..c8539fc0 --- /dev/null +++ b/webui/tools/tool.py @@ -0,0 +1,15 @@ +import streamlit as st + + +class Tool: + title: str + description: str + icon: str | None + + def __init__(self): + with st.expander(self.title, icon=self.icon): + st.write(self.description) + self.content() + + def content(self): + raise NotImplementedError