From acb55044d19d9a185b65e924a768c29683044490 Mon Sep 17 00:00:00 2001 From: Davide Passoni Date: Thu, 22 Feb 2024 17:46:34 +0100 Subject: [PATCH] More work on automatic map creation --- .gitignore | 1 + frontend/server/package.json | 3 +- frontend/server/routes/resources.js | 125 ++++++++++++++++++ frontend/website/src/constants/constants.ts | 4 +- .../python/generateMaps/.vscode/launch.json | 2 +- scripts/python/generateMaps/capture_screen.py | 81 ++++++++++++ .../configs/LasVegas/LasVegas.kml | 84 ++++++++++++ .../configs/LasVegas/LasVegas.yml | 4 + .../configs/screen_properties.yml | 10 ++ scripts/python/generateMaps/generate_tiles.py | 10 +- scripts/python/generateMaps/main.py | 66 +++++++++ scripts/python/generateMaps/requirements.txt | 23 ++++ .../python/generateMaps/take_screenshots.py | 75 ----------- 13 files changed, 408 insertions(+), 80 deletions(-) create mode 100644 scripts/python/generateMaps/capture_screen.py create mode 100644 scripts/python/generateMaps/configs/LasVegas/LasVegas.kml create mode 100644 scripts/python/generateMaps/configs/LasVegas/LasVegas.yml create mode 100644 scripts/python/generateMaps/configs/screen_properties.yml create mode 100644 scripts/python/generateMaps/main.py create mode 100644 scripts/python/generateMaps/requirements.txt delete mode 100644 scripts/python/generateMaps/take_screenshots.py 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 - - - -