# -*- coding: utf-8 -*-
|
|
__author__ = 'Marcel Dancak'
|
__date__ = 'April 2019'
|
__copyright__ = '(C) 2019 by Lutra Consulting Limited'
|
|
import os
|
import math
|
import re
|
import urllib.parse
|
from uuid import uuid4
|
|
import sqlite3
|
from osgeo import gdal
|
from qgis.PyQt.QtCore import QSize, Qt, QByteArray, QBuffer
|
from qgis.PyQt.QtGui import QColor, QImage, QPainter
|
from qgis.core import (QgsProcessingException,
|
QgsProcessingParameterEnum,
|
QgsProcessingParameterNumber,
|
QgsProcessingParameterBoolean,
|
QgsProcessingParameterString,
|
QgsProcessingParameterExtent,
|
QgsProcessingParameterColor,
|
QgsProcessingOutputFile,
|
QgsProcessingParameterFileDestination,
|
QgsProcessingParameterFolderDestination,
|
QgsGeometry,
|
QgsRectangle,
|
QgsMapSettings,
|
QgsCoordinateTransform,
|
QgsCoordinateReferenceSystem,
|
QgsMapRendererCustomPainterJob,
|
QgsLabelingEngineSettings,
|
QgsApplication,
|
QgsExpressionContextUtils,
|
QgsProcessingAlgorithm)
|
from processing.algs.qgis.QgisAlgorithm import QgisAlgorithm
|
import threading
|
from concurrent.futures import ThreadPoolExecutor
|
from processing.core.ProcessingConfig import ProcessingConfig
|
|
|
# TMS functions taken from https://alastaira.wordpress.com/2011/07/06/converting-tms-tile-coordinates-to-googlebingosm-tile-coordinates/ #spellok
|
def tms(ytile, zoom):
|
n = 2.0 ** zoom
|
ytile = n - ytile - 1
|
return int(ytile)
|
|
|
# Math functions taken from https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames #spellok
|
def deg2num(lat_deg, lon_deg, zoom):
|
lat_rad = math.radians(lat_deg)
|
n = 2.0 ** zoom
|
xtile = int((lon_deg + 180.0) / 360.0 * n)
|
ytile = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * n)
|
return (xtile, ytile)
|
|
|
# Math functions taken from https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames #spellok
|
def num2deg(xtile, ytile, zoom):
|
n = 2.0 ** 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)
|
|
|
class Tile:
|
|
def __init__(self, x, y, z):
|
self.x = x
|
self.y = y
|
self.z = z
|
|
def extent(self):
|
lat1, lon1 = num2deg(self.x, self.y, self.z)
|
lat2, lon2 = num2deg(self.x + 1, self.y + 1, self.z)
|
return [lon1, lat2, lon2, lat1]
|
|
|
class MetaTile:
|
|
def __init__(self):
|
# list of tuple(row index, column index, Tile)
|
self.tiles = []
|
|
def add_tile(self, row, column, tile):
|
self.tiles.append((row, column, tile))
|
|
def rows(self):
|
return max([r for r, _, _ in self.tiles]) + 1
|
|
def columns(self):
|
return max([c for _, c, _ in self.tiles]) + 1
|
|
def extent(self):
|
_, _, first = self.tiles[0]
|
_, _, last = self.tiles[-1]
|
lat1, lon1 = num2deg(first.x, first.y, first.z)
|
lat2, lon2 = num2deg(last.x + 1, last.y + 1, first.z)
|
return [lon1, lat2, lon2, lat1]
|
|
|
def get_metatiles(extent, zoom, size=4):
|
west_edge, south_edge, east_edge, north_edge = extent
|
left_tile, top_tile = deg2num(north_edge, west_edge, zoom)
|
right_tile, bottom_tile = deg2num(south_edge, east_edge, zoom)
|
|
metatiles = {}
|
for i, x in enumerate(range(left_tile, right_tile + 1)):
|
for j, y in enumerate(range(top_tile, bottom_tile + 1)):
|
meta_key = '{}:{}'.format(int(i / size), int(j / size))
|
if meta_key not in metatiles:
|
metatiles[meta_key] = MetaTile()
|
metatile = metatiles[meta_key]
|
metatile.add_tile(i % size, j % size, Tile(x, y, zoom))
|
|
return list(metatiles.values())
|
|
|
class TilesXYZAlgorithmBase(QgisAlgorithm):
|
EXTENT = 'EXTENT'
|
ZOOM_MIN = 'ZOOM_MIN'
|
ZOOM_MAX = 'ZOOM_MAX'
|
DPI = 'DPI'
|
BACKGROUND_COLOR = 'BACKGROUND_COLOR'
|
TILE_FORMAT = 'TILE_FORMAT'
|
QUALITY = 'QUALITY'
|
METATILESIZE = 'METATILESIZE'
|
|
def initAlgorithm(self, config=None):
|
self.addParameter(QgsProcessingParameterExtent(self.EXTENT, self.tr('Extent')))
|
self.addParameter(QgsProcessingParameterNumber(self.ZOOM_MIN,
|
self.tr('Minimum zoom'),
|
minValue=0,
|
maxValue=25,
|
defaultValue=12))
|
self.addParameter(QgsProcessingParameterNumber(self.ZOOM_MAX,
|
self.tr('Maximum zoom'),
|
minValue=0,
|
maxValue=25,
|
defaultValue=12))
|
self.addParameter(QgsProcessingParameterNumber(self.DPI,
|
self.tr('DPI'),
|
minValue=48,
|
maxValue=600,
|
defaultValue=96))
|
self.addParameter(QgsProcessingParameterColor(self.BACKGROUND_COLOR,
|
self.tr('Background color'),
|
defaultValue=QColor(Qt.transparent),
|
optional=True))
|
self.formats = ['PNG', 'JPG']
|
self.addParameter(QgsProcessingParameterEnum(self.TILE_FORMAT,
|
self.tr('Tile format'),
|
self.formats,
|
defaultValue=0))
|
self.addParameter(QgsProcessingParameterNumber(self.QUALITY,
|
self.tr('Quality (JPG only)'),
|
minValue=1,
|
maxValue=100,
|
defaultValue=75))
|
self.addParameter(QgsProcessingParameterNumber(self.METATILESIZE,
|
self.tr('Metatile size'),
|
minValue=1,
|
maxValue=20,
|
defaultValue=4))
|
self.thread_nr_re = re.compile('[0-9]+$') # thread number regex
|
|
def prepareAlgorithm(self, parameters, context, feedback):
|
project = context.project()
|
visible_layers = [item.layer() for item in project.layerTreeRoot().findLayers() if item.isVisible()]
|
self.layers = [l for l in project.layerTreeRoot().layerOrder() if l in visible_layers]
|
return True
|
|
def renderSingleMetatile(self, metatile):
|
if self.feedback.isCanceled():
|
return
|
# Haven't found a better way to break than to make all the new threads return instantly
|
|
if "Dummy" in threading.current_thread().name or len(self.settingsDictionary) == 1: # single thread testing
|
threadSpecificSettings = list(self.settingsDictionary.values())[0]
|
else:
|
thread_nr = self.thread_nr_re.search(threading.current_thread().name)[0] # terminating number only
|
threadSpecificSettings = self.settingsDictionary[thread_nr]
|
|
size = QSize(self.tile_width * metatile.rows(), self.tile_height * metatile.columns())
|
extent = QgsRectangle(*metatile.extent())
|
threadSpecificSettings.setExtent(self.wgs_to_dest.transformBoundingBox(extent))
|
threadSpecificSettings.setOutputSize(size)
|
|
# Append MapSettings scope in order to update map variables (e.g @map_scale) with new extent data
|
exp_context = threadSpecificSettings.expressionContext()
|
exp_context.appendScope(QgsExpressionContextUtils.mapSettingsScope(threadSpecificSettings))
|
threadSpecificSettings.setExpressionContext(exp_context)
|
|
image = QImage(size, QImage.Format_ARGB32_Premultiplied)
|
image.fill(self.color)
|
dpm = threadSpecificSettings.outputDpi() / 25.4 * 1000
|
image.setDotsPerMeterX(dpm)
|
image.setDotsPerMeterY(dpm)
|
painter = QPainter(image)
|
job = QgsMapRendererCustomPainterJob(threadSpecificSettings, painter)
|
job.renderSynchronously()
|
painter.end()
|
|
for r, c, tile in metatile.tiles:
|
tileImage = image.copy(self.tile_width * r, self.tile_height * c, self.tile_width, self.tile_height)
|
self.writer.write_tile(tile, tileImage)
|
|
# to stop thread sync issues
|
with self.progressThreadLock:
|
self.progress += 1
|
self.feedback.setProgress(100 * (self.progress / self.totalMetatiles))
|
|
def generate(self, writer, parameters, context, feedback):
|
self.feedback = feedback
|
feedback.setProgress(1)
|
|
extent = self.parameterAsExtent(parameters, self.EXTENT, context)
|
self.min_zoom = self.parameterAsInt(parameters, self.ZOOM_MIN, context)
|
self.max_zoom = self.parameterAsInt(parameters, self.ZOOM_MAX, context)
|
dpi = self.parameterAsInt(parameters, self.DPI, context)
|
self.color = self.parameterAsColor(parameters, self.BACKGROUND_COLOR, context)
|
self.tile_format = self.formats[self.parameterAsEnum(parameters, self.TILE_FORMAT, context)]
|
self.quality = self.parameterAsInt(parameters, self.QUALITY, context)
|
self.metatilesize = self.parameterAsInt(parameters, self.METATILESIZE, context)
|
self.maxThreads = int(ProcessingConfig.getSetting(ProcessingConfig.MAX_THREADS))
|
try:
|
self.tile_width = self.parameterAsInt(parameters, self.TILE_WIDTH, context)
|
self.tile_height = self.parameterAsInt(parameters, self.TILE_HEIGHT, context)
|
except AttributeError:
|
self.tile_width = 256
|
self.tile_height = 256
|
|
wgs_crs = QgsCoordinateReferenceSystem('EPSG:4326')
|
dest_crs = QgsCoordinateReferenceSystem('EPSG:3857')
|
|
project = context.project()
|
self.src_to_wgs = QgsCoordinateTransform(project.crs(), wgs_crs, context.transformContext())
|
self.wgs_to_dest = QgsCoordinateTransform(wgs_crs, dest_crs, context.transformContext())
|
# without re-writing, we need a different settings for each thread to stop async errors
|
# naming doesn't always line up, but the last number does
|
self.settingsDictionary = {str(i): QgsMapSettings() for i in range(self.maxThreads)}
|
for thread in self.settingsDictionary:
|
self.settingsDictionary[thread].setOutputImageFormat(QImage.Format_ARGB32_Premultiplied)
|
self.settingsDictionary[thread].setDestinationCrs(dest_crs)
|
self.settingsDictionary[thread].setLayers(self.layers)
|
self.settingsDictionary[thread].setOutputDpi(dpi)
|
if self.tile_format == 'PNG':
|
self.settingsDictionary[thread].setBackgroundColor(self.color)
|
|
# disable partial labels (they would be cut at the edge of tiles)
|
labeling_engine_settings = self.settingsDictionary[thread].labelingEngineSettings()
|
labeling_engine_settings.setFlag(QgsLabelingEngineSettings.UsePartialCandidates, False)
|
self.settingsDictionary[thread].setLabelingEngineSettings(labeling_engine_settings)
|
|
# Transfer context scopes to MapSettings
|
self.settingsDictionary[thread].setExpressionContext(context.expressionContext())
|
|
self.wgs_extent = self.src_to_wgs.transformBoundingBox(extent)
|
self.wgs_extent = [self.wgs_extent.xMinimum(), self.wgs_extent.yMinimum(), self.wgs_extent.xMaximum(),
|
self.wgs_extent.yMaximum()]
|
|
metatiles_by_zoom = {}
|
self.totalMetatiles = 0
|
allMetatiles = []
|
for zoom in range(self.min_zoom, self.max_zoom + 1):
|
metatiles = get_metatiles(self.wgs_extent, zoom, self.metatilesize)
|
metatiles_by_zoom[zoom] = metatiles
|
allMetatiles += metatiles
|
self.totalMetatiles += len(metatiles)
|
|
lab_buffer_px = 100
|
self.progress = 0
|
|
tile_params = {
|
'format': self.tile_format,
|
'quality': self.quality,
|
'width': self.tile_width,
|
'height': self.tile_height,
|
'min_zoom': self.min_zoom,
|
'max_zoom': self.max_zoom,
|
'extent': self.wgs_extent,
|
}
|
writer.set_parameters(tile_params)
|
self.writer = writer
|
|
self.progressThreadLock = threading.Lock()
|
if self.maxThreads > 1:
|
feedback.pushConsoleInfo(self.tr('Using {max_threads} CPU Threads:').format(max_threads=self.maxThreads))
|
for zoom in range(self.min_zoom, self.max_zoom + 1):
|
feedback.pushConsoleInfo(self.tr('Generating tiles for zoom level: {zoom}').format(zoom=zoom))
|
with ThreadPoolExecutor(max_workers=self.maxThreads) as threadPool:
|
threadPool.map(self.renderSingleMetatile, metatiles_by_zoom[zoom])
|
else:
|
feedback.pushConsoleInfo(self.tr('Using 1 CPU Thread:'))
|
for zoom in range(self.min_zoom, self.max_zoom + 1):
|
feedback.pushConsoleInfo(self.tr('Generating tiles for zoom level: {zoom}').format(zoom=zoom))
|
for i, metatile in enumerate(metatiles_by_zoom[zoom]):
|
self.renderSingleMetatile(metatile)
|
|
writer.close()
|
|
def checkParameterValues(self, parameters, context):
|
min_zoom = self.parameterAsInt(parameters, self.ZOOM_MIN, context)
|
max_zoom = self.parameterAsInt(parameters, self.ZOOM_MAX, context)
|
if max_zoom < min_zoom:
|
return False, self.tr('Invalid zoom levels range.')
|
|
return super().checkParameterValues(parameters, context)
|
|
|
|
########################################################################
|
# Directory
|
########################################################################
|
LEAFLET_TEMPLATE = '''
|
<!DOCTYPE html>
|
<html>
|
<head>
|
<title>{tilesetname}</title>
|
<meta charset="utf-8" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.5.1/dist/leaflet.css"
|
integrity="sha512-xwE/Az9zrjBIphAcBb3F6JVqxf46+CDLwfLMHloNu6KEQCAWi6HcDUbeOfBIptF7tcCzusKFjFw2yuvEpDL9wQ=="
|
crossorigin=""/>
|
<script src="https://unpkg.com/leaflet@1.5.1/dist/leaflet.js"
|
integrity="sha512-GffPMF3RvMeYyc1LWMHtK8EbPv0iNZ8/oTtHPx9/cc2ILxQ+u905qIwdpULaqDkyBKgOaB57QTMg7ztg8Jm2Og=="
|
crossorigin=""></script>
|
<style type="text/css">
|
body {{
|
margin: 0;
|
padding: 0;
|
}}
|
html, body, #map{{
|
width: 100%;
|
height: 100%;
|
}}
|
</style>
|
</head>
|
<body>
|
<div id="map"></div>
|
<script>
|
var map = L.map('map').setView([{centery}, {centerx}], {avgzoom});
|
L.tileLayer({tilesource}, {{
|
minZoom: {minzoom},
|
maxZoom: {maxzoom},
|
tms: {tms},
|
attribution: 'Generated by TilesXYZ'
|
}}).addTo(map);
|
</script>
|
</body>
|
</html>
|
'''
|
|
|
class DirectoryWriter:
|
|
def __init__(self, folder, is_tms):
|
self.folder = folder
|
self.is_tms = is_tms
|
|
def set_parameters(self, tile_params):
|
self.format = tile_params.get('format', 'PNG')
|
self.quality = tile_params.get('quality', -1)
|
|
def write_tile(self, tile, image):
|
directory = os.path.join(self.folder, str(tile.z), str(tile.x))
|
os.makedirs(directory, exist_ok=True)
|
ytile = tile.y
|
if self.is_tms:
|
ytile = tms(ytile, tile.z)
|
path = os.path.join(directory, '{}.{}'.format(ytile, self.format.lower()))
|
image.save(path, self.format, self.quality)
|
return path
|
|
def close(self):
|
pass
|
|
|
class TilesXYZAlgorithmDirectory(TilesXYZAlgorithmBase):
|
TMS_CONVENTION = 'TMS_CONVENTION'
|
OUTPUT_DIRECTORY = 'OUTPUT_DIRECTORY'
|
OUTPUT_HTML = 'OUTPUT_HTML'
|
TILE_WIDTH = 'TILE_WIDTH'
|
TILE_HEIGHT = 'TILE_HEIGHT'
|
|
def initAlgorithm(self, config=None):
|
super(TilesXYZAlgorithmDirectory, self).initAlgorithm()
|
self.addParameter(QgsProcessingParameterNumber(self.TILE_WIDTH,
|
self.tr('Tile width'),
|
minValue=1,
|
maxValue=4096,
|
defaultValue=256))
|
self.addParameter(QgsProcessingParameterNumber(self.TILE_HEIGHT,
|
self.tr('Tile height'),
|
minValue=1,
|
maxValue=4096,
|
defaultValue=256))
|
self.addParameter(QgsProcessingParameterBoolean(self.TMS_CONVENTION,
|
self.tr('Use inverted tile Y axis (TMS convention)'),
|
defaultValue=False,
|
optional=True))
|
self.addParameter(QgsProcessingParameterFolderDestination(self.OUTPUT_DIRECTORY,
|
self.tr('Output directory'),
|
optional=True))
|
self.addParameter(QgsProcessingParameterFileDestination(self.OUTPUT_HTML,
|
self.tr('Output html (Leaflet)'),
|
self.tr('HTML files (*.html)'),
|
optional=True))
|
|
def name(self):
|
return 'tilesxyzdirectory'
|
|
def displayName(self):
|
return self.tr('Generate XYZ tiles (Directory)')
|
|
def group(self):
|
return self.tr('Raster tools')
|
|
def groupId(self):
|
return 'rastertools'
|
|
def processAlgorithm(self, parameters, context, feedback):
|
is_tms = self.parameterAsBoolean(parameters, self.TMS_CONVENTION, context)
|
output_html = self.parameterAsString(parameters, self.OUTPUT_HTML, context)
|
output_dir = self.parameterAsString(parameters, self.OUTPUT_DIRECTORY, context)
|
if not output_dir:
|
raise QgsProcessingException(self.tr('You need to specify output directory.'))
|
|
writer = DirectoryWriter(output_dir, is_tms)
|
self.generate(writer, parameters, context, feedback)
|
|
results = {'OUTPUT_DIRECTORY': output_dir}
|
|
if output_html:
|
output_dir_safe = urllib.parse.quote(output_dir.replace('\\', '/'))
|
html_code = LEAFLET_TEMPLATE.format(
|
tilesetname="Leaflet Preview",
|
centerx=self.wgs_extent[0] + (self.wgs_extent[2] - self.wgs_extent[0]) / 2,
|
centery=self.wgs_extent[1] + (self.wgs_extent[3] - self.wgs_extent[1]) / 2,
|
avgzoom=(self.max_zoom + self.min_zoom) / 2,
|
tilesource="'file:///{}/{{z}}/{{x}}/{{y}}.{}'".format(output_dir_safe, self.tile_format.lower()),
|
minzoom=self.min_zoom,
|
maxzoom=self.max_zoom,
|
tms='true' if is_tms else 'false'
|
)
|
with open(output_html, "w") as fh:
|
fh.write(html_code)
|
results['OUTPUT_HTML'] = output_html
|
|
return results
|