diff --git a/.gitignore b/.gitignore
index 32f89517..088a483d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -37,3 +37,4 @@ package-lock.json
frontend/server/public/plugins/controltipsplugin/index.js
frontend/website/plugins/controltips/index.js
/frontend/server/public/maps
+*.pyc
diff --git a/frontend/server/package.json b/frontend/server/package.json
index 03a5346d..d612c932 100644
--- a/frontend/server/package.json
+++ b/frontend/server/package.json
@@ -23,9 +23,10 @@
"regedit": "^5.1.2",
"save": "^2.9.0",
"sha256": "^0.2.0",
+ "sharp": "^0.33.2",
"srtm-elevation": "^2.1.2",
"tcp-ping-port": "^1.0.1",
"uuid": "^9.0.1",
"yargs": "^17.7.2"
}
-}
\ No newline at end of file
+}
diff --git a/frontend/server/routes/resources.js b/frontend/server/routes/resources.js
index f3fd6198..ebb34ad7 100644
--- a/frontend/server/routes/resources.js
+++ b/frontend/server/routes/resources.js
@@ -1,4 +1,7 @@
const express = require('express');
+const sharp = require('sharp')
+const fs = require('fs');
+const pfs = require('fs/promises')
const router = express.Router();
router.get('/theme/*', function (req, res, next) {
@@ -15,4 +18,126 @@ router.put('/theme/:newTheme', function (req, res, next) {
res.end("Ok");
});
+router.get('/maps/:map/:z/:x/:y.png', async function (req, res, next) {
+ let map = req.params.map;
+ let x = req.params.x;
+ let y = req.params.y;
+ let z = req.params.z;
+
+ if (fs.existsSync(`.\\public\\maps\\${map}`)) {
+ if (!await renderImage(map, z, x, y, res)) {
+ /* No image was found */
+ res.sendStatus(404);
+ }
+ } else {
+ /* The requested map does not exist */
+ res.sendStatus(404);
+ }
+});
+
+async function renderImage(map, z, x, y, res, recursionDepth = 0) {
+ if (recursionDepth == 20) {
+ console.log("Render image, maximum recursion depth reached")
+ /* We have reached limit recusion depth, something went wrong */
+ return false;
+ }
+ /* If the requested image exists, send it straight away */
+ if (fs.existsSync(`.\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`)) {
+ res.sendFile(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`);
+ return true;
+ } else {
+ /* If the requested image doesn't exist check if there is a "source" tile at a lower zoom level we can split up */
+ let sourceZoom = z - 1;
+ let sourceX = Math.floor(x / 2);
+ let sourceY = Math.floor(y / 2);
+
+ /* Keep decreasing the zoom level until we either find an image or reach maximum zoom out and must return false */
+ while (sourceZoom >= 0) {
+ /* We have found a possible source image */
+ if (fs.existsSync(`.\\public\\maps\\${map}\\${sourceZoom}\\${sourceX}\\${sourceY}.png`)) {
+ /* Split the image into four. We can retry up to 10 times to clear any race condition on the files */
+ let retries = 10;
+ while (!await splitTile(map, sourceZoom, sourceX, sourceY) && retries > 0) {
+ await new Promise(r => setTimeout(r, 1000));
+ retries--;
+ }
+ /* Check if we exited because we reached the maximum retry value */
+ if (retries != 0) {
+ /* Recursively recall the function now that we have a new "source" image */
+ return await renderImage(map, z, x, y, res, recursionDepth + 1);
+ } else {
+ return false;
+ }
+ } else {
+ /* Keep searching at a higher level */
+ sourceZoom = sourceZoom - 1;
+ sourceX = Math.floor(sourceX / 2);
+ sourceY = Math.floor(sourceY / 2);
+ }
+ }
+ return false;
+ }
+}
+
+async function splitTile(map, z, x, y) {
+ try {
+ /* Load the source image */
+ let img = sharp(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`);
+
+ /* Create the necessary folders */
+ await pfs.mkdir(`${process.cwd()}\\public\\maps\\${map}\\${z + 1}\\${2*x}`, { recursive: true })
+ await pfs.mkdir(`${process.cwd()}\\public\\maps\\${map}\\${z + 1}\\${2*x + 1}`, { recursive: true })
+
+ /* Split the image into four parts */
+ await resizePromise(img, 0, 0, map, z + 1, 2 * x, 2 * y);
+ img = sharp(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`);
+ await resizePromise(img, 128, 0, map, z + 1, 2 * x + 1, 2 * y);
+ img = sharp(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`);
+ await resizePromise(img, 0, 128, map, z + 1, 2 * x, 2 * y + 1);
+ img = sharp(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`);
+ await resizePromise(img, 128, 128, map, z + 1, 2 * x + 1, 2 * y + 1);
+ return true;
+ } catch (err) {
+ if (err.code !== 'EBUSY') {
+ console.error(err);
+ }
+ return false;
+ }
+}
+
+/* Returns a promise, extracts a 128x128 pixel chunk from an image, resizes it to 256x256, and saves it to file */
+function resizePromise(img, left, top, map, z, x, y) {
+ return new Promise((res, rej) => {
+ if (fs.existsSync(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`)) {
+ res(true);
+ }
+ img.extract({ left: left, top: top, width: 128, height: 128 }).resize(256, 256).toFile(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.temp.png`, function (err) {
+ if (err) {
+ rej(err);
+ } else {
+ try {
+ fs.renameSync(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.temp.png`, `${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`);
+ } catch (err) {
+ if (err.code === 'EBUSY') {
+ /* The resource is busy, someone else is writing or renaming it. Reject the promise so we can try again */
+ rej(err);
+ } else if (err.code === 'ENOENT') {
+ /* Someone else renamed the file already so this is not a real error */
+ if (fs.existsSync(`${process.cwd()}\\public\\maps\\${map}\\${z}\\${x}\\${y}.png`)) {
+ res(true);
+ }
+ /* Something odd happened, reject and try again */
+ else {
+ rej(err);
+ }
+ } else {
+ rej(err);
+ }
+ }
+ res(true)
+ }
+ });
+ })
+}
+
module.exports = router;
diff --git a/frontend/website/src/constants/constants.ts b/frontend/website/src/constants/constants.ts
index e544d31f..52e185cd 100644
--- a/frontend/website/src/constants/constants.ts
+++ b/frontend/website/src/constants/constants.ts
@@ -192,9 +192,9 @@ export const mapLayers = {
attribution: 'CyclOSM | Map data: © OpenStreetMap contributors'
},
"DCS": {
- urlTemplate: 'http://localhost:3000/maps/dcs/{z}/{x}/{y}.png',
+ urlTemplate: 'http://localhost:3000/resources/maps/dcs/{z}/{x}/{y}.png',
minZoom: 16,
- maxZoom: 16,
+ maxZoom: 20,
attribution: 'Eagle Dynamics'
}
}
diff --git a/scripts/python/generateMaps/.vscode/launch.json b/scripts/python/generateMaps/.vscode/launch.json
index 306f58eb..01d13453 100644
--- a/scripts/python/generateMaps/.vscode/launch.json
+++ b/scripts/python/generateMaps/.vscode/launch.json
@@ -10,7 +10,7 @@
"request": "launch",
"program": "${file}",
"console": "integratedTerminal",
- "justMyCode": true
+ "args": ["./configs/LasVegas/LasVegas.yml"]
}
]
}
\ No newline at end of file
diff --git a/scripts/python/generateMaps/capture_screen.py b/scripts/python/generateMaps/capture_screen.py
new file mode 100644
index 00000000..6cc4ef1f
--- /dev/null
+++ b/scripts/python/generateMaps/capture_screen.py
@@ -0,0 +1,81 @@
+import math
+import requests
+import json
+import pyautogui
+import time
+import os
+
+from pyproj import Geod
+from fastkml import kml
+from shapely import wkt
+
+def deg_to_num(lat_deg, lon_deg, zoom):
+ lat_rad = math.radians(lat_deg)
+ n = 1 << zoom
+ xtile = int((lon_deg + 180.0) / 360.0 * n)
+ ytile = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
+ return xtile, ytile
+
+def num_to_deg(xtile, ytile, zoom):
+ n = 1 << zoom
+ lon_deg = xtile / n * 360.0 - 180.0
+ lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
+ lat_deg = math.degrees(lat_rad)
+ return lat_deg, lon_deg
+
+def run(map_config):
+ with open(map_config["boundary_file"], 'rt', encoding="utf-8") as bp:
+ # Read the config file
+ doc = bp.read()
+ k = kml.KML()
+ k.from_string(doc)
+
+ geod = Geod(ellps="WGS84")
+ features = [f for f in list(k.features()) if not f.isopen]
+ print(f"Found {len(features)} closed features in the provided kml file")
+
+ area = 0
+ for feature in features:
+ for sub_feature in list(feature.features()):
+ geo = sub_feature.geometry
+
+ start_lat = geo.bounds[1]
+ start_lng = geo.bounds[0]
+ end_lat = geo.bounds[3]
+ end_lng = geo.bound[2]
+
+ # Find the starting and ending points
+ start_X, start_Y = deg_to_num(start_lat, start_lng, zoom)
+ end_X, end_Y = deg_to_num(end_lat, end_lng, zoom)
+
+ time.sleep(2)
+
+ # Create output folder
+ if not os.path.exists("output"):
+ os.mkdir("output")
+
+ # Start looping
+ n = 1
+ total = math.floor((end_X - start_X) / 2) * math.floor((end_Y - start_Y) / 2)
+ for X in range(start_X, end_X, 2):
+ for Y in range(start_Y, end_Y, 2):
+ # Find the center of the screen
+ center_lat, center_lng = num_to_deg(X + 1, Y + 1, zoom)
+ center_alt = camera_altitude(center_lat)
+
+ # Making PUT request
+ data = json.dumps({'lat': center_lat, 'lng': center_lng, 'alt': center_alt})
+ r = requests.put('http://localhost:8080', data = data)
+
+ # Take and save screenshot
+ screenshot = pyautogui.screenshot()
+ screenshot.save(f"output/{X + 1}_{Y + 1}_{zoom}.png")
+
+ time.sleep(0.5)
+
+ print(f"Shot {n} of {total}")
+ n = n + 1
+
+
+
+
diff --git a/scripts/python/generateMaps/configs/LasVegas/LasVegas.kml b/scripts/python/generateMaps/configs/LasVegas/LasVegas.kml
new file mode 100644
index 00000000..a401dd8c
--- /dev/null
+++ b/scripts/python/generateMaps/configs/LasVegas/LasVegas.kml
@@ -0,0 +1,84 @@
+
+
+
+ Senza titolo
+
+
+
+
+
+
+
+
+ normal
+ #__managed_style_1E6AF60F852F06CED14C
+
+
+ highlight
+ #__managed_style_29D7120C702F06CED14C
+
+
+
+ Poligono senza titolo
+
+ -115.7575513617584
+ 36.45909683572987
+ 1668.83938821393
+ 0
+ 0
+ 35
+ 382627.9679017514
+ absolute
+
+ #__managed_style_05AC9C65832F06CED14C
+
+
+
+
+ -115.5765603362792,35.92113103850846,0 -114.7667743850415,35.914903046417,0 -114.7800419428627,36.39467581209903,0 -115.6198023719551,36.39886214564519,0 -115.5765603362792,35.92113103850846,0
+
+
+
+
+
+
+
diff --git a/scripts/python/generateMaps/configs/LasVegas/LasVegas.yml b/scripts/python/generateMaps/configs/LasVegas/LasVegas.yml
new file mode 100644
index 00000000..b4f8eb93
--- /dev/null
+++ b/scripts/python/generateMaps/configs/LasVegas/LasVegas.yml
@@ -0,0 +1,4 @@
+{
+ 'output_directory': './LasVegas', # Where to save the output files
+ 'boundary_file': './configs/LasVegas/LasVegas.kml'
+}
\ No newline at end of file
diff --git a/scripts/python/generateMaps/configs/screen_properties.yml b/scripts/python/generateMaps/configs/screen_properties.yml
new file mode 100644
index 00000000..6e7cd752
--- /dev/null
+++ b/scripts/python/generateMaps/configs/screen_properties.yml
@@ -0,0 +1,10 @@
+{
+ 'width': 1920, # The width of your screen, in pixels
+ 'height': 1080, # The height of your screen, in pixels
+ 'geo_resolution': 1.0 # The resolution of the map on the screen, in meters per pixel.
+ # To measure this value, first set the F10 map at the desired zoom level.
+ # Then, use F10's map measure tool, and measure the width of the screen in meters.
+ # Finally, divide that value by the width in pixels.
+ # A good value would be around 1 meter per pixel, meaning a 1920px wide map would measure about 1 nautical mile across on the F10 map
+ # Lower values will produce higher resolution maps, but beware of space usage!
+}
\ No newline at end of file
diff --git a/scripts/python/generateMaps/generate_tiles.py b/scripts/python/generateMaps/generate_tiles.py
index a6663147..05302ec9 100644
--- a/scripts/python/generateMaps/generate_tiles.py
+++ b/scripts/python/generateMaps/generate_tiles.py
@@ -1,12 +1,20 @@
import os
from PIL import Image
import concurrent.futures
+import math
+
+# correction parameters
+
+# NTTR
+rotation = math.degrees(0.01895)
+scale = 0.973384
zoom = 16
path = "output"
def crop_image(filename):
- img = Image.open(os.path.join(path, filename))
+ img = Image.open(os.path.join(path, filename)).rotate(-rotation)
+ img = img.resize((math.floor(img.width * scale), math.floor(img.height * scale)))
center_X, center_Y = filename.removesuffix(".png").split("_")
center_X = int(center_X)
center_Y = int(center_Y)
diff --git a/scripts/python/generateMaps/main.py b/scripts/python/generateMaps/main.py
new file mode 100644
index 00000000..d2f19ae7
--- /dev/null
+++ b/scripts/python/generateMaps/main.py
@@ -0,0 +1,66 @@
+import sys
+import yaml
+from pyproj import Geod
+from fastkml import kml
+from shapely import wkt
+from datetime import timedelta
+
+import capture_screen
+
+if len(sys.argv) == 1:
+ print("Please provide a configuration file as first argument. You can also drop the configuration file on this script to run it.")
+else:
+ config_file = sys.argv[1]
+ print(f"Using config file: {config_file}")
+
+ with open('configs/screen_properties.yml', 'r') as sp:
+ with open(config_file, 'r') as cp:
+ screen_config = yaml.safe_load(sp)
+ map_config = yaml.safe_load(cp)
+
+ print("#################################################################################################################################################")
+ print("# IMPORTANT NOTE: the screen properties must be configured according to your screen and desired zoom level. Make sure you set them accordingly. #")
+ print("#################################################################################################################################################")
+
+ print("Screen parameters:")
+ print(f"-> Screen width: {screen_config["width"]}px")
+ print(f"-> Screen height: {screen_config["height"]}px")
+ print(f"-> Geographic resolution: {screen_config["geo_resolution"]} meters/pixel")
+
+ print("Map parameters:")
+ print(f"-> Output directory: {map_config["output_directory"]}")
+ print(f"-> Boundary file: {map_config["boundary_file"]}")
+
+ with open(map_config["boundary_file"], 'rt', encoding="utf-8") as bp:
+ # Read the config file and compute the total area of the covered map
+ doc = bp.read()
+ k = kml.KML()
+ k.from_string(doc)
+
+ geod = Geod(ellps="WGS84")
+ features = [f for f in list(k.features()) if not f.isopen]
+ print(f"Found {len(features)} closed features in the provided kml file")
+
+ area = 0
+ for feature in features:
+ for sub_feature in list(feature.features()):
+ geo = sub_feature.geometry
+ area += abs(geod.geometry_area_perimeter(wkt.loads(geo.wkt))[0])
+
+ tile_size = 256 * screen_config["geo_resolution"] # meters
+ tiles_per_screenshot = int(screen_config["width"] / 256) * int(screen_config["height"] / 256)
+ tiles_num = int(area / (tile_size * tile_size))
+ screenshots_num = int(tiles_num / tiles_per_screenshot)
+ total_time = int(screenshots_num / 1.0)
+
+ print(f"Total area: {int(area / 1e6)} square kilometers")
+ print(f"Estimated number of tiles: {tiles_num}")
+ print(f"Estimated number of screenshots: {screenshots_num}")
+ print(f"Estimated time to complete: {timedelta(seconds=total_time)} (hh:mm:ss)")
+ print("The script is ready to go. After you press any key, it will wait for 5 seconds, and then it will start.")
+
+ input("Press any key to continue...")
+ capture_screen.run(map_config)
+
+
+
diff --git a/scripts/python/generateMaps/requirements.txt b/scripts/python/generateMaps/requirements.txt
new file mode 100644
index 00000000..43e52dfd
--- /dev/null
+++ b/scripts/python/generateMaps/requirements.txt
@@ -0,0 +1,23 @@
+certifi==2024.2.2
+charset-normalizer==3.3.2
+fastkml==0.12
+idna==3.6
+MouseInfo==0.1.3
+numpy==1.26.4
+pillow==10.2.0
+PyAutoGUI==0.9.54
+pygeoif==0.7
+PyGetWindow==0.0.9
+PyMsgBox==1.0.9
+pyperclip==1.8.2
+pyproj==3.6.1
+PyRect==0.2.0
+PyScreeze==0.1.30
+python-dateutil==2.8.2
+pytweening==1.2.0
+PyYAML==6.0.1
+requests==2.31.0
+setuptools==69.1.0
+shapely==2.0.3
+six==1.16.0
+urllib3==2.2.1
diff --git a/scripts/python/generateMaps/take_screenshots.py b/scripts/python/generateMaps/take_screenshots.py
deleted file mode 100644
index 160f7e4b..00000000
--- a/scripts/python/generateMaps/take_screenshots.py
+++ /dev/null
@@ -1,75 +0,0 @@
-import math
-import requests
-import json
-import pyautogui
-import time
-import os
-
-# parameters
-start_lat = 36.31669444 # degs
-start_lng = -115.38336111 # degs
-
-end_lat = 35.93336111 # degs
-end_lng = -114.95002778 # degs
-
-fov = 10 # deg
-zoom = 16
-
-# constants
-C = 40075016.686 # meters
-
-def deg_to_num(lat_deg, lon_deg, zoom):
- lat_rad = math.radians(lat_deg)
- n = 1 << zoom
- xtile = int((lon_deg + 180.0) / 360.0 * n)
- ytile = int((1.0 - math.asinh(math.tan(lat_rad)) / math.pi) / 2.0 * n)
- return xtile, ytile
-
-def num_to_deg(xtile, ytile, zoom):
- n = 1 << zoom
- lon_deg = xtile / n * 360.0 - 180.0
- lat_rad = math.atan(math.sinh(math.pi * (1 - 2 * ytile / n)))
- lat_deg = math.degrees(lat_rad)
- return lat_deg, lon_deg
-
-def camera_altitude(lat_deg):
- mpp = C * math.cos(math.radians(lat_deg)) / math.pow(2, zoom + 8)
- d = mpp * 1920
- alt = d / 2 * 1 / math.tan(math.radians(fov) / 2)
- return alt
-
-# Find the starting and ending points
-start_X, start_Y = deg_to_num(start_lat, start_lng, zoom)
-end_X, end_Y = deg_to_num(end_lat, end_lng, zoom)
-
-time.sleep(2)
-
-# Create output folder
-if not os.path.exists("output"):
- os.mkdir("output")
-
-# Start looping
-n = 1
-total = math.floor((end_X - start_X) / 2) * math.floor((end_Y - start_Y) / 2)
-for X in range(start_X, end_X, 2):
- for Y in range(start_Y, end_Y, 2):
- # Find the center of the screen
- center_lat, center_lng = num_to_deg(X + 1, Y + 1, zoom)
- center_alt = camera_altitude(center_lat)
-
- # Making PUT request
- data = json.dumps({'lat': center_lat, 'lng': center_lng, 'alt': center_alt})
- r = requests.put('http://localhost:8080', data = data)
-
- # Take and save screenshot
- screenshot = pyautogui.screenshot()
- screenshot.save(f"output/{X + 1}_{Y + 1}_{zoom}.png")
-
- time.sleep(0.5)
-
- print(f"Shot {n} of {total}")
- n = n + 1
-
-
-
-