Completed automatic algorithm

This commit is contained in:
Pax1601 2024-02-26 08:55:03 +01:00
parent 2e1c3ec4b9
commit c74258e3ad
12 changed files with 247 additions and 194 deletions

View File

@ -29,127 +29,6 @@ module.exports = function (configLocation) {
res.sendStatus(404);
}
});
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)
}
});
})
}
return router;
}

View File

@ -252,7 +252,9 @@ export class Map extends L.Map {
var options: L.TileLayerOptions = {
attribution: layerData.attribution,
minZoom: layerData.minZoom,
maxZoom: layerData.maxZoom
maxZoom: layerData.maxZoom,
minNativeZoom: layerData.minNativeZoom,
maxNativeZoom: layerData.maxNativeZoom
};
this.#layer = new L.TileLayer(layerData.urlTemplate, options);
}

View File

@ -17,9 +17,10 @@
},
"additionalMaps": {
"DCS": {
"urlTemplate": "http://localhost:3000/resources/maps/dcs/{z}/{x}/{y}.png",
"urlTemplate": "http://localhost:3000/maps/dcs/{z}/{x}/{y}.jpg",
"minZoom": 8,
"maxZoom": 20,
"maxNativeZoom": 17,
"attribution": "Eagle Dynamics"
}
}

View File

@ -10,7 +10,7 @@
"request": "launch",
"program": "main.py",
"console": "integratedTerminal",
"args": ["./configs/LasVegas/LasVegas.yml"]
"args": ["./configs/NTTR/config.yml"]
}
]
}

View File

@ -1,4 +0,0 @@
{
'output_directory': '.\LasVegas', # Where to save the output files
'boundary_file': '.\configs\LasVegas\LasVegas.kml'
}

View File

@ -0,0 +1,6 @@
{
'output_directory': '.\LasVegas', # Where to save the output files
'boundary_file': '.\configs\LasVegas\boundary.kml', # Input kml file setting the boundary of the map to create
'zoom_factor': 0.02, # [0: maximum zoom in (things look very big), 1: maximum zoom out (things look very small)]
'geo_width': 1.14
}

View File

@ -0,0 +1,84 @@
<?xml version="1.0" encoding="UTF-8"?>
<kml xmlns="http://www.opengis.net/kml/2.2" xmlns:gx="http://www.google.com/kml/ext/2.2" xmlns:kml="http://www.opengis.net/kml/2.2" xmlns:atom="http://www.w3.org/2005/Atom">
<Document>
<name>Senza titolo</name>
<gx:CascadingStyle kml:id="__managed_style_1847AF2A832F1651A60F">
<Style>
<IconStyle>
<Icon>
<href>https://earth.google.com/earth/rpc/cc/icon?color=1976d2&amp;id=2000&amp;scale=4</href>
</Icon>
<hotSpot x="64" y="128" xunits="pixels" yunits="insetPixels"/>
</IconStyle>
<LabelStyle>
</LabelStyle>
<LineStyle>
<color>ff2dc0fb</color>
<width>4</width>
</LineStyle>
<PolyStyle>
<color>40ffffff</color>
</PolyStyle>
<BalloonStyle>
<displayMode>hide</displayMode>
</BalloonStyle>
</Style>
</gx:CascadingStyle>
<gx:CascadingStyle kml:id="__managed_style_2C7F63B5A12F1651A60F">
<Style>
<IconStyle>
<scale>1.2</scale>
<Icon>
<href>https://earth.google.com/earth/rpc/cc/icon?color=1976d2&amp;id=2000&amp;scale=4</href>
</Icon>
<hotSpot x="64" y="128" xunits="pixels" yunits="insetPixels"/>
</IconStyle>
<LabelStyle>
</LabelStyle>
<LineStyle>
<color>ff2dc0fb</color>
<width>6</width>
</LineStyle>
<PolyStyle>
<color>40ffffff</color>
</PolyStyle>
<BalloonStyle>
<displayMode>hide</displayMode>
</BalloonStyle>
</Style>
</gx:CascadingStyle>
<StyleMap id="__managed_style_043F3D3A202F1651A60F">
<Pair>
<key>normal</key>
<styleUrl>#__managed_style_1847AF2A832F1651A60F</styleUrl>
</Pair>
<Pair>
<key>highlight</key>
<styleUrl>#__managed_style_2C7F63B5A12F1651A60F</styleUrl>
</Pair>
</StyleMap>
<Placemark id="0F15269F3D2F1651A60F">
<name>NTTR</name>
<LookAt>
<longitude>-117.2703145690532</longitude>
<latitude>37.39557832822189</latitude>
<altitude>1754.517427470683</altitude>
<heading>359.4706465490362</heading>
<tilt>0</tilt>
<gx:fovy>35</gx:fovy>
<range>1393300.815671235</range>
<altitudeMode>absolute</altitudeMode>
</LookAt>
<styleUrl>#__managed_style_043F3D3A202F1651A60F</styleUrl>
<Polygon>
<outerBoundaryIs>
<LinearRing>
<coordinates>
-119.7864240113604,34.44074394422174,0 -112.42342379541,34.34217218687283,0 -112.1179107081757,39.75928290264283,0 -120.0041004413372,39.79698539473655,0 -119.7864240113604,34.44074394422174,0
</coordinates>
</LinearRing>
</outerBoundaryIs>
</Polygon>
</Placemark>
</Document>
</kml>

View File

@ -0,0 +1,5 @@
{
'output_directory': '.\NTTR', # Where to save the output files
'boundary_file': '.\configs\NTTR\boundary.kml', # Input kml file setting the boundary of the map to create
'zoom_factor': 0.5 # [0: maximum zoom in (things look very big), 1: maximum zoom out (things look very small)]
}

View File

@ -1,10 +1,4 @@
{
'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!
'width': 1920, # The width of your screen, in pixels
'height': 1080 # The height of your screen, in pixels
}

View File

@ -1,5 +1,9 @@
import sys
import yaml
import json
import requests
import time
from pyproj import Geod
from fastkml import kml
from shapely import wkt
@ -18,20 +22,19 @@ else:
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(f"-> Screen width: {screen_config['width']}px")
print(f"-> Screen height: {screen_config['height']}px")
print("Map parameters:")
print(f"-> Output directory: {map_config["output_directory"]}")
print(f"-> Boundary file: {map_config["boundary_file"]}")
print(f"-> Output directory: {map_config['output_directory']}")
print(f"-> Boundary file: {map_config['boundary_file']}")
print(f"-> Zoom factor: {map_config['zoom_factor']}")
with open(map_config["boundary_file"], 'rt', encoding="utf-8") as bp:
if 'geo_width' in map_config:
print(f"-> Geo width: {map_config['geo_width']}NM")
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()
@ -48,8 +51,17 @@ else:
print(f"Found {len(features)} features in the provided kml file")
tile_size = 256 * screen_config["geo_resolution"] # meters
tiles_per_screenshot = int(screen_config["width"] / 256) * int(screen_config["height"] / 256)
if 'geo_width' not in map_config:
# Let the user input the size of the screen to compute resolution
data = json.dumps({'lat': features[0].geometry.bounds[1], 'lng': features[0].geometry.bounds[0], 'alt': 1350 + map_config['zoom_factor'] * (25000 - 1350)})
r = requests.put('http://localhost:8080', data = data)
print("The F10 map in your DCS installation was setup. Please, use the measure tool and measure the width of the screen in Nautical Miles")
map_config['geo_width'] = input("Insert the width of the screen in Nautical Miles: ")
map_config['mpps'] = float(map_config['geo_width']) * 1852 / screen_config['width']
tile_size = 256 * map_config['mpps'] # 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)
@ -57,11 +69,11 @@ else:
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 enter, it will wait for 5 seconds, then it will start.")
print(f"Estimated time to complete: {timedelta(seconds=total_time * 0.15)} (hh:mm:ss)")
input("Press enter to continue...")
map_generator.run(map_config)

View File

@ -4,11 +4,14 @@ import pyautogui
import time
import os
import yaml
import json
from fastkml import kml
from shapely import wkt, Point
from PIL import Image
from concurrent import futures
from os import listdir
from os.path import isfile, isdir, join
# global counters
fut_counter = 0
@ -16,6 +19,7 @@ tot_futs = 0
# constants
C = 40075016.686 # meters, Earth equatorial circumference
R = C / (2 * math.pi)
def deg_to_num(lat_deg, lon_deg, zoom):
lat_rad = math.radians(lat_deg)
@ -48,33 +52,24 @@ def done_callback(fut):
fut_counter += 1
printProgressBar(fut_counter, tot_futs)
def extract_tiles(n, screenshots_coordinates, params):
f = params["f"]
zoom = params["zoom"]
output_directory = params["output_directory"]
n_width = params["n_width"]
n_height = params["n_height"]
screen_resolution = params["screen_resolution"]
mpps = params["mpps"]
def extract_tiles(n, screenshots_XY, params):
f = params['f']
zoom = params['zoom']
output_directory = params['output_directory']
n_width = params['n_width']
n_height = params['n_height']
coords = screenshots_coordinates[n]
XY = screenshots_XY[n]
if (os.path.exists(os.path.join(output_directory, "screenshots", f"{f}_{n}.jpg"))):
# Open the source screenshot
img = Image.open(os.path.join(output_directory, "screenshots", f"{f}_{n}.jpg"))
# Scale the image so that tiles are 256x256
scale = screen_resolution / mpps
w, h = img.size
img = img.resize((int(w * scale), int(h * scale)))
# Compute the Web Mercator Projection position of the top left corner of the most centered tile
lat = coords[0]
lng = coords[1]
X_center, Y_center = deg_to_num(lat, lng, zoom)
X_center, Y_center = XY[0], XY[1]
# Compute the position of the top left corner of the top left tile
start_x = w / 2 - n_width / 2 * 256
start_y = h / 2 - n_height / 2 * 256
start_x = img.width / 2 - n_width / 2 * 256
start_y = img.height / 2 - n_height / 2 * 256
# Iterate on the grid
for column in range(0, n_width):
@ -96,17 +91,44 @@ def extract_tiles(n, screenshots_coordinates, params):
n += 1
else:
raise Exception(f"{os.path.join(output_directory, "screenshots", f"{f}_{n}.jpg")} missing")
raise Exception(f"{os.path.join(output_directory, 'screenshots', f'{f}_{n}.jpg')} missing")
def merge_tiles(base_path, zoom, tile):
X = tile[0]
Y = tile[1]
positions = [(0, 0), (0, 1), (1, 0), (1, 1)]
dst = Image.new('RGB', (256, 256), (0, 0, 0, 0))
for i in range(0, 4):
if os.path.exists(os.path.join(base_path, str(zoom), str(2*X + positions[i][0]), f"{2*Y + positions[i][1]}.jpg")):
im = Image.open(os.path.join(base_path, str(zoom), str(2*X + positions[i][0]), f"{2*Y + positions[i][1]}.jpg")).resize((128, 128))
else:
im = Image.new('RGB', (128, 128), (0, 0, 0, 0))
dst.paste(im, (positions[i][0] * 128, positions[i][1] * 128))
if not os.path.exists(os.path.join(base_path, str(zoom - 1), str(X))):
try:
os.mkdir(os.path.join(base_path, str(zoom - 1), str(X)))
except FileExistsError:
pass
except Exception as e:
raise e
dst.save(os.path.join(base_path, str(zoom - 1), str(X), f"{Y}.jpg"), quality=95)
def run(map_config):
global tot_futs, fut_counter
with open('configs/screen_properties.yml', 'r') as sp:
screen_config = yaml.safe_load(sp)
# Create output folders
output_directory = map_config["output_directory"]
output_directory = map_config['output_directory']
if not os.path.exists(output_directory):
os.mkdir(output_directory)
skip_screenshots = False
if not os.path.exists(os.path.join(output_directory, "screenshots")):
os.mkdir(os.path.join(output_directory, "screenshots"))
else:
@ -116,10 +138,10 @@ def run(map_config):
os.mkdir(os.path.join(output_directory, "tiles"))
# Compute the optimal zoom level
usable_width = screen_config["width"] - 200 # Keep a margin around the center
usable_height = screen_config["height"] - 200 # Keep a margin around the center
usable_width = screen_config['width'] - 400 # Keep a margin around the center
usable_height = screen_config['height'] - 400 # Keep a margin around the center
with open(map_config["boundary_file"], 'rt', encoding="utf-8") as bp:
with open(map_config['boundary_file'], 'rt', encoding="utf-8") as bp:
# Read the config file
doc = bp.read()
k = kml.KML()
@ -134,7 +156,8 @@ def run(map_config):
# Iterate over all the closed features in the kml file
f = 1
for feature in features:
geo = sub_feature.geometry
########### Take screenshots
geo = feature.geometry
# Define the boundary rect around the area
start_lat = geo.bounds[3]
@ -143,15 +166,14 @@ def run(map_config):
end_lng = geo.bounds[2]
# Find the zoom level that better approximates the provided resolution
screen_resolution = screen_config['geo_resolution']
mpps_delta = [abs(compute_mpps((start_lat + end_lat) / 2, z) - screen_resolution) for z in range(0, 21)]
mpps_delta = [abs(compute_mpps((start_lat + end_lat) / 2, z) - map_config['mpps']) for z in range(0, 21)]
zoom = mpps_delta.index(min(mpps_delta))
print(f"Feature {f} of {len(features)}, using zoom level {zoom}")
# Find the maximum dimension of the tiles at the given resolution
mpps = compute_mpps(end_lat, zoom)
d = 256 * mpps / screen_resolution
d = 256 * mpps / map_config['mpps']
n_height = math.floor(usable_height / d)
n_width = math.floor(usable_width / d)
@ -163,35 +185,60 @@ def run(map_config):
end_X, end_Y = deg_to_num(end_lat, end_lng, zoom)
# Find all the X, Y coordinates inside of the provided area
screenshots_coordinates = []
screenshots_XY = []
for X in range(start_X, end_X, n_width):
for Y in range(start_Y, end_Y, n_height):
lat, lng = num_to_deg(X, Y, zoom)
p = Point(lng, lat)
if p.within(wkt.loads(geo.wkt)):
screenshots_coordinates.append((lat, lng))
screenshots_XY.append((X, Y))
print(f"Feature {f} of {len(features)}, {len(screenshots_coordinates)} screenshots will be taken")
print(f"Feature {f} of {len(features)}, {len(screenshots_XY)} screenshots will be taken")
# Start looping
if not skip_screenshots:
print(f"Feature {f} of {len(features)}, taking screenshots...")
n = 0
for coords in screenshots_coordinates:
for XY in screenshots_XY:
# Making PUT request
#data = json.dumps({'lat': coords[0], 'lng': coords[1]})
#r = requests.put('http://localhost:8080', data = data)
# If the number of rows or columns is odd, we need to take the picture at the CENTER of the tile!
lat, lng = num_to_deg(XY[0] + (n_width % 2) / 2, XY[1] + (n_height % 2) / 2, zoom)
data = json.dumps({'lat': lat, 'lng': lng, 'alt': 1350 + map_config['zoom_factor'] * (25000 - 1350)})
r = requests.put('http://localhost:8080', data = data)
geo_data = json.loads(r.text)
time.sleep(0.1)
## Take and save screenshot
# Take and save screenshot. The response to the put request contains data, among which there is the north rotation at that point.
screenshot = pyautogui.screenshot()
screenshot.save(os.path.join(output_directory, "screenshots", f"{f}_{n}.jpg"))
printProgressBar(n + 1, len(screenshots_coordinates))
# Scale the screenshot to account for Mercator Map Deformation
lat1, lng1 = num_to_deg(XY[0], XY[1], zoom)
lat2, lng2 = num_to_deg(XY[0] + 1, XY[1] + 1, zoom)
deltaLat = abs(lat2 - lat1)
deltaLng = abs(lng2 - lng1)
# Compute the height and width the screenshot should have
m_height = math.radians(deltaLat) * R * n_height
m_width = math.radians(deltaLng) * R * math.cos(math.radians(lat1)) * n_width
# Compute the height and width the screenshot has
s_height = map_config['mpps'] * 256 * n_height
s_width = map_config['mpps'] * 256 * n_width
# Compute the scaling required to achieve that
sx = s_width / m_width
sy = s_height / m_height
# Resize, rotate and save the screenshot
screenshot.resize((int(sx * screenshot.width), int(sy * screenshot.height))).rotate(math.degrees(geo_data['northRotation'])).save(os.path.join(output_directory, "screenshots", f"{f}_{n}.jpg"), quality=95)
printProgressBar(n + 1, len(screenshots_XY))
n += 1
# Extract the tiles
########### Extract the tiles
if not os.path.exists(os.path.join(output_directory, "tiles", str(zoom))):
os.mkdir(os.path.join(output_directory, "tiles", str(zoom)))
@ -201,15 +248,12 @@ def run(map_config):
"output_directory": output_directory,
"n_width": n_width,
"n_height": n_height,
"screen_resolution": screen_resolution,
"mpps": mpps
}
# Extract the tiles with parallel thread execution
with futures.ThreadPoolExecutor() as executor:
print(f"Feature {f} of {len(features)}, extracting tiles...")
global tot_futs, fut_counter
futs = [executor.submit(extract_tiles, n, screenshots_coordinates, params) for n in range(0, len(screenshots_coordinates))]
futs = [executor.submit(extract_tiles, n, screenshots_XY, params) for n in range(0, len(screenshots_XY))]
tot_futs = len(futs)
fut_counter = 0
[fut.add_done_callback(done_callback) for fut in futs]
@ -219,6 +263,36 @@ def run(map_config):
print(f"Feature {f} of {len(features)} completed!")
f += 1
########### Assemble tiles to get lower zoom levels
for current_zoom in range(zoom, 8, -1):
Xs = [int(d) for d in listdir(os.path.join(output_directory, "tiles", str(current_zoom))) if isdir(join(output_directory, "tiles", str(current_zoom), d))]
existing_tiles = []
for X in Xs:
Ys = [int(f.removesuffix(".jpg")) for f in listdir(os.path.join(output_directory, "tiles", str(current_zoom), str(X))) if isfile(join(output_directory, "tiles", str(current_zoom), str(X), f))]
for Y in Ys:
existing_tiles.append((X, Y))
tiles_to_produce = []
for tile in existing_tiles:
if (int(tile[0] / 2), int(tile[1] / 2)) not in tiles_to_produce:
tiles_to_produce.append((int(tile[0] / 2), int(tile[1] / 2)))
# Merge the tiles with parallel thread execution
with futures.ThreadPoolExecutor() as executor:
print(f"Merging tiles for zoom level {current_zoom - 1}...")
if not os.path.exists(os.path.join(output_directory, "tiles", str(current_zoom - 1))):
os.mkdir(os.path.join(output_directory, "tiles", str(current_zoom - 1)))
futs = [executor.submit(merge_tiles, os.path.join(output_directory, "tiles"), current_zoom, tile) for tile in tiles_to_produce]
tot_futs = len(futs)
fut_counter = 0
[fut.add_done_callback(done_callback) for fut in futs]
[fut.result() for fut in futures.as_completed(futs)]