Merge pull request #54321 from alexbruy/processing-port-tile-xyz

[processing] port XYZ tiles algorithms
This commit is contained in:
Alexander Bruy 2023-09-13 10:58:21 +03:00 committed by GitHub
commit 4401ac792b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 735 additions and 682 deletions

View File

@ -233,16 +233,6 @@ qgis:statisticsbycategories: >
qgis:texttofloat: >
This algorithm modifies the type of a given attribute in a vector layer, converting a text attribute containing numeric strings into a numeric attribute.
qgis:tilesxyzdirectory: >
This algorithm generates raster XYZ tiles of map canvas content.
Tile images are saved as individual images in directory structure.
qgis:tilesxyzmbtiles: >
This algorithm generates raster XYZ tiles of map canvas content.
Tile images are saved as a single file in the “MBTiles” format.
qgis:tininterpolation: >
Generates a Triangulated Irregular Network (TIN) interpolation of a point vector layer.

View File

@ -74,7 +74,6 @@ from .SetVectorStyle import SetVectorStyle
from .SpatialJoinSummary import SpatialJoinSummary
from .StatisticsByCategories import StatisticsByCategories
from .TextToFloat import TextToFloat
from .TilesXYZ import TilesXYZAlgorithmDirectory, TilesXYZAlgorithmMBTiles
from .TinInterpolation import TinInterpolation
from .TopoColors import TopoColor
from .UniqueValues import UniqueValues
@ -140,8 +139,6 @@ class QgisAlgorithmProvider(QgsProcessingProvider):
SpatialJoinSummary(),
StatisticsByCategories(),
TextToFloat(),
TilesXYZAlgorithmDirectory(),
TilesXYZAlgorithmMBTiles(),
TinInterpolation(),
TopoColor(),
UniqueValues(),

View File

@ -1,669 +0,0 @@
"""
***************************************************************************
TilesXYZ.py
---------------------
Date : April 2019
Copyright : (C) 2019 by Lutra Consulting Limited
Email : marcel.dancak@lutraconsulting.co.uk
***************************************************************************
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
***************************************************************************
"""
__author__ = 'Marcel Dancak'
__date__ = 'April 2019'
__copyright__ = '(C) 2019 by Lutra Consulting Limited'
import os
import math
import re
from typing import List, Tuple
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,
QgsProcessingParameterDefinition,
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: float, zoom: int) -> int:
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: float, lon_deg: float, zoom: int) -> Tuple[int, int]:
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: int, ytile: int, zoom: int) -> Tuple[float, float]:
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: int, y: int, z: int):
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: List[Tuple[int, int, Tile]] = []
def add_tile(self, row: int, column: int, tile: Tile):
self.tiles.append((row, column, tile))
def rows(self) -> int:
return max([r for r, _, _ in self.tiles]) + 1
def columns(self) -> int:
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_tile_based_extent(extent: Tuple[float, float, float, float], zoom: int) -> Tuple[int, int, int, int]:
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)
return left_tile, top_tile, right_tile, bottom_tile
def get_metatiles(extent: Tuple[float, float, float, float], zoom: int, size=4):
left_tile, top_tile, right_tile, bottom_tile = get_tile_based_extent(extent, zoom)
meta_row_max = int((bottom_tile - top_tile) / size) + 1
meta_col_max = int((right_tile - left_tile) / size) + 1
for meta_col in range(meta_col_max):
for meta_row in range(meta_row_max):
metatile = MetaTile()
for i in range(size):
for j in range(size):
x = left_tile + meta_col * size + i
y = top_tile + meta_row * size + j
if x <= right_tile and y <= bottom_tile:
metatile.add_tile(i, j, Tile(x, y, zoom))
yield metatile
def get_metatiles_count(extent: Tuple[float, float, float, float], zoom: int, size=4) -> int:
left_tile, top_tile, right_tile, bottom_tile = get_tile_based_extent(extent, zoom)
meta_row_max = int((bottom_tile - top_tile) / size) + 1
meta_col_max = int((right_tile - left_tile) / size) + 1
return meta_row_max * meta_col_max
class TilesXYZAlgorithmBase(QgisAlgorithm):
EXTENT = 'EXTENT'
ZOOM_MIN = 'ZOOM_MIN'
ZOOM_MAX = 'ZOOM_MAX'
DPI = 'DPI'
BACKGROUND_COLOR = 'BACKGROUND_COLOR'
ANTIALIAS = 'ANTIALIAS'
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.addParameter(QgsProcessingParameterBoolean(self.ANTIALIAS,
self.tr('Enable antialiasing'),
defaultValue=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: 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 = round(threadSpecificSettings.outputDpi() / 25.4 * 1000)
image.setDotsPerMeterX(dpm)
image.setDotsPerMeterY(dpm)
painter = QPainter(image)
job = QgsMapRendererCustomPainterJob(threadSpecificSettings, painter)
job.renderSynchronously()
painter.end()
# For analysing metatiles (labels, etc.)
# metatile_dir = os.path.join(output_dir, str(zoom))
# os.makedirs(metatile_dir, exist_ok=True)
# image.save(os.path.join(metatile_dir, 'metatile_%s.png' % i))
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))
self.feedback.setProgressText(self.tr('Generated: {progress}/{total} metatiles').format(progress=self.progress, total=self.totalMetatiles))
def generate(self, writer, parameters, context, feedback):
self.feedback = feedback
feedback.setProgress(1)
project = context.project()
# Transform extent to match project CRS
extent_original = self.parameterAsExtent(parameters, self.EXTENT, context)
extent_crs = self.parameterAsExtentCrs(parameters, self.EXTENT, context)
extent_crs_to_project_crs = QgsCoordinateTransform(extent_crs, project.crs(), context.transformContext())
extent = extent_crs_to_project_crs.transformBoundingBox(extent_original)
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.antialias = self.parameterAsBoolean(parameters, self.ANTIALIAS, 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 = context.maximumThreads()
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')
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].setTransformContext(context.transformContext())
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)
self.settingsDictionary[thread].setFlag(QgsMapSettings.Antialiasing, self.antialias)
# 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
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
self.totalMetatiles += get_metatiles_count(self.wgs_extent, zoom, self.metatilesize)
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:
for result in threadPool.map(self.renderSingleMetatile, metatiles_by_zoom[zoom]):
# re-raise exceptions from threads
_ = result
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)
########################################################################
# MBTiles
########################################################################
class MBTilesWriter:
def __init__(self, filename):
base_dir = os.path.dirname(filename)
os.makedirs(base_dir, exist_ok=True)
self.filename = filename
def set_parameters(self, tile_params):
self.extent = tile_params.get('extent')
self.tile_width = tile_params.get('width', 256)
self.tile_height = tile_params.get('height', 256)
self.min_zoom = tile_params.get('min_zoom')
self.max_zoom = tile_params.get('max_zoom')
tile_format = tile_params['format']
options = []
if tile_format == 'JPG':
tile_format = 'JPEG'
options = ['QUALITY=%s' % tile_params.get('quality', 75)]
driver = gdal.GetDriverByName('MBTiles')
ds = driver.Create(self.filename, 1, 1, 1, options=['TILE_FORMAT=%s' % tile_format] + options)
ds = None
# faster sqlite processing for parallel access https://stackoverflow.com/questions/15143871/simplest-way-to-retry-sqlite-query-if-db-is-locked
self._execute_sqlite("PRAGMA journal_mode=WAL")
self._execute_sqlite(
"INSERT INTO metadata(name, value) VALUES ('{}', '{}');".format('minzoom', self.min_zoom),
"INSERT INTO metadata(name, value) VALUES ('{}', '{}');".format('maxzoom', self.max_zoom),
# will be set properly after writing all tiles
"INSERT INTO metadata(name, value) VALUES ('{}', '');".format('bounds')
)
self._zoom = None
def _execute_sqlite(self, *commands):
# wait_timeout = default timeout is 5 seconds increase it for slower disk access and more Threads to 120 seconds
# isolation_level = None Uses sqlite AutoCommit and disable phyton transaction management feature. https://docs.python.org/3/library/sqlite3.html#sqlite3-controlling-transactions
conn = sqlite3.connect(self.filename, timeout=120, isolation_level=None)
for cmd in commands:
conn.execute(cmd)
conn.commit()
conn.close()
def _init_zoom_layer(self, zoom):
self._zoom_ds = None
west_edge, south_edge, east_edge, north_edge = self.extent
first_tile = Tile(*deg2num(north_edge, west_edge, zoom), zoom)
last_tile = Tile(*deg2num(south_edge, east_edge, zoom), zoom)
first_tile_extent = first_tile.extent()
last_tile_extent = last_tile.extent()
zoom_extent = [
first_tile_extent[0],
last_tile_extent[1],
last_tile_extent[2],
first_tile_extent[3]
]
bounds = ','.join(map(str, zoom_extent))
self._execute_sqlite(f"UPDATE metadata SET value='{bounds}' WHERE name='bounds'")
self._zoom_ds = gdal.OpenEx(self.filename, 1, open_options=['ZOOM_LEVEL=%s' % first_tile.z])
self._first_tile = first_tile
self._zoom = zoom
def write_tile(self, tile, image):
if tile.z != self._zoom:
self._init_zoom_layer(tile.z)
data = QByteArray()
buff = QBuffer(data)
image.save(buff, 'PNG')
mmap_name = '/vsimem/' + uuid4().hex
gdal.FileFromMemBuffer(mmap_name, data.data())
gdal_dataset = gdal.Open(mmap_name)
data = gdal_dataset.ReadRaster(0, 0, self.tile_width, self.tile_height)
gdal_dataset = None
gdal.Unlink(mmap_name)
xoff = (tile.x - self._first_tile.x) * self.tile_width
yoff = (tile.y - self._first_tile.y) * self.tile_height
self._zoom_ds.WriteRaster(xoff, yoff, self.tile_width, self.tile_height, data)
def close(self):
self._zoom_ds = None
bounds = ','.join(map(str, self.extent))
self._execute_sqlite(f"UPDATE metadata SET value='{bounds}' WHERE name='bounds'")
# Set Journal Mode back to default
self._execute_sqlite("PRAGMA journal_mode=DELETE")
class TilesXYZAlgorithmMBTiles(TilesXYZAlgorithmBase):
OUTPUT_FILE = 'OUTPUT_FILE'
def initAlgorithm(self, config=None):
super().initAlgorithm()
self.addParameter(QgsProcessingParameterFileDestination(self.OUTPUT_FILE,
self.tr('Output file (for MBTiles)'),
self.tr('MBTiles files (*.mbtiles)'),
optional=True))
def name(self):
return 'tilesxyzmbtiles'
def displayName(self):
return self.tr('Generate XYZ tiles (MBTiles)')
def group(self):
return self.tr('Raster tools')
def groupId(self):
return 'rastertools'
def flags(self):
return super().flags() | QgsProcessingAlgorithm.FlagRequiresProject
def processAlgorithm(self, parameters, context, feedback):
output_file = self.parameterAsString(parameters, self.OUTPUT_FILE, context)
if not output_file:
raise QgsProcessingException(self.tr('You need to specify output filename.'))
writer = MBTilesWriter(output_file)
self.generate(writer, parameters, context, feedback)
results = {'OUTPUT_FILE': output_file}
return results
########################################################################
# 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.9.3/dist/leaflet.css"
integrity="sha384-o/2yZuJZWGJ4s/adjxVW71R+EO/LyCwdQfP5UWSgX/w87iiTXuvDZaejd3TsN7mf"
crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.3/dist/leaflet.js"
integrity="sha384-okbbMvvx/qfQkmiQKfd5VifbKZ/W8p1qIsWvE1ROPUfHWsDcC8/BnHohF7vPg2T6"
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', {{ attributionControl: false }} ).setView([{centery}, {centerx}], {avgzoom});
L.control.attribution( {{ prefix: false }} ).addTo( map );
{osm}
var tilesource_layer = L.tileLayer({tilesource}, {{
minZoom: {minzoom},
maxZoom: {maxzoom},
tms: {tms},
attribution: '{attribution}'
}}).addTo(map);
</script>
</body>
</html>
'''
OSM_TEMPLATE = '''
var osm_layer = L.tileLayer('https://tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{
minZoom: {minzoom},
maxZoom: {maxzoom},
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}}).addTo(map);
'''
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, f'{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'
HTML_TITLE = 'HTML_TITLE'
HTML_ATTRIBUTION = 'HTML_ATTRIBUTION'
HTML_OSM = 'HTML_OSM'
def initAlgorithm(self, config=None):
super().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))
html_title_param = QgsProcessingParameterString(self.HTML_TITLE,
self.tr('Leaflet HTML output title'),
optional=True,
defaultValue='')
html_attrib_param = QgsProcessingParameterString(self.HTML_ATTRIBUTION,
self.tr('Leaflet HTML output attribution'),
optional=True,
defaultValue='')
html_osm_param = QgsProcessingParameterBoolean(self.HTML_OSM,
self.tr('Include OpenStreetMap basemap in Leaflet HTML output'),
defaultValue=False,
optional=True)
for param in (html_title_param, html_attrib_param, html_osm_param):
param.setFlags(param.flags() | QgsProcessingParameterDefinition.FlagAdvanced)
self.addParameter(param)
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)
html_title = self.parameterAsString(parameters, self.HTML_TITLE, context)
html_attribution = self.parameterAsString(parameters, self.HTML_ATTRIBUTION, context)
is_osm = self.parameterAsBoolean(parameters, self.HTML_OSM, 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=html_title if html_title else "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=f"'file:///{output_dir_safe}/{{z}}/{{x}}/{{y}}.{self.tile_format.lower()}'",
minzoom=self.min_zoom,
maxzoom=self.max_zoom,
tms='true' if is_tms else 'false',
attribution=html_attribution if html_attribution else self.tr('Created by QGIS algorithm:') + ' ' + self.displayName(),
osm=OSM_TEMPLATE.format(
minzoom=self.min_zoom,
maxzoom=self.max_zoom
) if is_osm else ''
)
with open(output_html, "w") as fh:
fh.write(html_code)
results['OUTPUT_HTML'] = output_html
return results

View File

@ -237,6 +237,7 @@ set(QGIS_ANALYSIS_SRCS
processing/qgsalgorithmvectorize.cpp
processing/qgsalgorithmwedgebuffers.cpp
processing/qgsalgorithmwritevectortiles.cpp
processing/qgsalgorithmxyztiles.cpp
processing/qgsalgorithmzonalhistogram.cpp
processing/qgsalgorithmzonalstatistics.cpp
processing/qgsalgorithmzonalstatisticsfeaturebased.cpp

View File

@ -0,0 +1,536 @@
/***************************************************************************
qgsalgorithmxyztiles.cpp
---------------------
begin : August 2023
copyright : (C) 2023 by Alexander Bruy
email : alexander dot bruy at gmail dot com
***************************************************************************/
/***************************************************************************
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
***************************************************************************/
#include "qgsalgorithmxyztiles.h"
#include <QBuffer>
#include "qgslayertree.h"
#include "qgslayertreelayer.h"
#include "qgsexpressioncontextutils.h"
///@cond PRIVATE
int tile2tms( const int y, const int zoom )
{
double n = std::pow( 2, zoom );
return ( int )std::floor( n - y - 1 );
}
int lon2tileX( const double lon, const int z )
{
return ( int )( std::floor( ( lon + 180.0 ) / 360.0 * ( 1 << z ) ) );
}
int lat2tileY( const double lat, const int z )
{
double latRad = lat * M_PI / 180.0;
return ( int )( std::floor( ( 1.0 - std::asinh( std::tan( latRad ) ) / M_PI ) / 2.0 * ( 1 << z ) ) );
}
double tileX2lon( const int x, const int z )
{
return x / ( double )( 1 << z ) * 360.0 - 180 ;
}
double tileY2lat( const int y, const int z )
{
double n = M_PI - 2.0 * M_PI * y / ( double )( 1 << z );
return 180.0 / M_PI * std::atan( 0.5 * ( std::exp( n ) - std::exp( -n ) ) );
}
void extent2TileXY( QgsRectangle extent, const int zoom, int &xMin, int &yMin, int &xMax, int &yMax )
{
xMin = lon2tileX( extent.xMinimum(), zoom );
yMin = lat2tileY( extent.yMinimum(), zoom );
xMax = lon2tileX( extent.xMaximum(), zoom );
yMax = lat2tileY( extent.xMaximum(), zoom );
}
QList< MetaTile > getMetatiles( const QgsRectangle extent, const int zoom, const int tileSize )
{
int minX = lon2tileX( extent.xMinimum(), zoom );
int minY = lat2tileY( extent.yMaximum(), zoom );
int maxX = lon2tileX( extent.xMaximum(), zoom );
int maxY = lat2tileY( extent.yMinimum(), zoom );;
int i = 0;
QMap< QString, MetaTile > tiles;
for ( int x = minX; x <= maxX; x++ )
{
int j = 0;
for ( int y = minY; y <= maxY; y++ )
{
QString key = QStringLiteral( "%1:%2" ).arg( ( int )( i / tileSize ) ).arg( ( int )( j / tileSize ) );
MetaTile tile = tiles.value( key, MetaTile() );
tile.addTile( i % tileSize, j % tileSize, Tile( x, y, zoom ) );
tiles.insert( key, tile );
j++;
}
i++;
}
return tiles.values();
}
////
QString QgsXyzTilesBaseAlgorithm::group() const
{
return QObject::tr( "Raster tools" );
}
QString QgsXyzTilesBaseAlgorithm::groupId() const
{
return QStringLiteral( "rastertools" );
}
QgsProcessingAlgorithm::Flags QgsXyzTilesBaseAlgorithm::flags() const
{
return QgsProcessingAlgorithm::flags() | QgsProcessingAlgorithm::FlagRequiresProject;
}
void QgsXyzTilesBaseAlgorithm::createCommonParameters()
{
addParameter( new QgsProcessingParameterExtent( QStringLiteral( "EXTENT" ), QObject::tr( "Extent" ) ) );
addParameter( new QgsProcessingParameterNumber( QStringLiteral( "ZOOM_MIN" ), QObject::tr( "Minimum zoom" ), QgsProcessingParameterNumber::Integer, 12, false, 0, 25 ) );
addParameter( new QgsProcessingParameterNumber( QStringLiteral( "ZOOM_MAX" ), QObject::tr( "Maximum zoom" ), QgsProcessingParameterNumber::Integer, 12, false, 0, 25 ) );
addParameter( new QgsProcessingParameterNumber( QStringLiteral( "DPI" ), QObject::tr( "DPI" ), QgsProcessingParameterNumber::Integer, 96, false, 48, 600 ) );
addParameter( new QgsProcessingParameterColor( QStringLiteral( "BACKGROUND_COLOR" ), QObject::tr( "Background color" ), QColor( Qt::transparent ), true, true ) );
addParameter( new QgsProcessingParameterBoolean( QStringLiteral( "ANTIALIAS" ), QObject::tr( "Enable antialiasing" ), true ) );
addParameter( new QgsProcessingParameterEnum( QStringLiteral( "TILE_FORMAT" ), QObject::tr( "Tile format" ), QStringList() << QStringLiteral( "PNG" ) << QStringLiteral( "JPG" ), false, 0 ) );
addParameter( new QgsProcessingParameterNumber( QStringLiteral( "QUALITY" ), QObject::tr( "Quality (JPG only)" ), QgsProcessingParameterNumber::Integer, 75, false, 1, 100 ) );
addParameter( new QgsProcessingParameterNumber( QStringLiteral( "METATILESIZE" ), QObject::tr( "Metatile size" ), QgsProcessingParameterNumber::Integer, 4, false, 1, 20 ) );
}
bool QgsXyzTilesBaseAlgorithm::prepareAlgorithm( const QVariantMap &parameters, QgsProcessingContext &context, QgsProcessingFeedback *feedback )
{
Q_UNUSED( feedback );
QgsProject *project = context.project();
const QList< QgsLayerTreeLayer * > projectLayers = project->layerTreeRoot()->findLayers();
QSet< QString > visibleLayers;
for ( const QgsLayerTreeLayer *layer : projectLayers )
{
if ( layer->isVisible() )
{
visibleLayers << layer->layer()->id();
}
}
QList< QgsMapLayer * > renderLayers = project->layerTreeRoot()->layerOrder();
for ( QgsMapLayer *layer : renderLayers )
{
if ( visibleLayers.contains( layer->id() ) )
{
QgsMapLayer *clonedLayer = layer->clone();
clonedLayer->moveToThread( nullptr );
mLayers << clonedLayer;
}
}
QgsRectangle extent = parameterAsExtent( parameters, QStringLiteral( "EXTENT" ), context );
QgsCoordinateReferenceSystem extentCrs = parameterAsExtentCrs( parameters, QStringLiteral( "EXTENT" ), context );
QgsCoordinateTransform ct( extentCrs, project->crs(), context.transformContext() );
mExtent = ct.transformBoundingBox( extent );
mMinZoom = parameterAsInt( parameters, QStringLiteral( "ZOOM_MIN" ), context );
mMaxZoom = parameterAsInt( parameters, QStringLiteral( "ZOOM_MAX" ), context );
mDpi = parameterAsInt( parameters, QStringLiteral( "DPI" ), context );
mBackgroundColor = parameterAsColor( parameters, QStringLiteral( "BACKGROUND_COLOR" ), context );
mAntialias = parameterAsBool( parameters, QStringLiteral( "ANTIALIAS" ), context );
mTileFormat = parameterAsEnum( parameters, QStringLiteral( "TILE_FORMAT" ), context ) ? QStringLiteral( "JPG" ) : QStringLiteral( "PNG" );
mJpgQuality = parameterAsInt( parameters, QStringLiteral( "QUALITY" ), context );
mMetaTileSize = parameterAsInt( parameters, QStringLiteral( "METATILESIZE" ), context );
mThreadsNumber = context.maximumThreads();
mTransformContext = context.transformContext();
mFeedback = feedback;
mWgs84Crs = QgsCoordinateReferenceSystem( "EPSG:4326" );
mMercatorCrs = QgsCoordinateReferenceSystem( "EPSG:3857" );
mSrc2Wgs = QgsCoordinateTransform( project->crs(), mWgs84Crs, context.transformContext() );
mWgs2Mercator = QgsCoordinateTransform( mWgs84Crs, mMercatorCrs, context.transformContext() );
mWgs84Extent = mSrc2Wgs.transformBoundingBox( mExtent );
if ( parameters.contains( QStringLiteral( "TILE_WIDTH" ) ) )
{
mTileWidth = parameterAsInt( parameters, QStringLiteral( "TILE_WIDTH" ), context );
}
if ( parameters.contains( QStringLiteral( "TILE_HEIGHT" ) ) )
{
mTileHeight = parameterAsInt( parameters, QStringLiteral( "TILE_HEIGHT" ), context );
}
return true;
}
void QgsXyzTilesBaseAlgorithm::startJobs()
{
while ( mRendererJobs.size() < mThreadsNumber )
{
MetaTile metaTile = mMetaTiles.takeFirst();
QgsMapSettings settings;
settings.setExtent( mWgs2Mercator.transformBoundingBox( metaTile.extent() ) );
settings.setOutputImageFormat( QImage::Format_ARGB32_Premultiplied );
settings.setTransformContext( mTransformContext );
settings.setDestinationCrs( mMercatorCrs );
settings.setLayers( mLayers );
settings.setOutputDpi( mDpi );
if ( mTileFormat == QStringLiteral( "PNG" ) )
{
settings.setBackgroundColor( mBackgroundColor );
}
QSize size( mTileWidth * metaTile.rows, mTileHeight * metaTile.cols );
settings.setOutputSize( size );
QgsLabelingEngineSettings labelingSettings = settings.labelingEngineSettings();
labelingSettings.setFlag( Qgis::LabelingFlag::UsePartialCandidates, false );
settings.setLabelingEngineSettings( labelingSettings );
QgsExpressionContext exprContext = settings.expressionContext();
exprContext.appendScope( QgsExpressionContextUtils::mapSettingsScope( settings ) );
settings.setExpressionContext( exprContext );
QgsMapRendererSequentialJob *job = new QgsMapRendererSequentialJob( settings );
mRendererJobs.insert( job, metaTile );
QObject::connect( job, &QgsMapRendererJob::finished, mFeedback, [ this, job ]() { processMetaTile( job ); } );
job->start();
}
}
// Native XYZ tiles (directory) algorithm
QString QgsXyzTilesDirectoryAlgorithm::name() const
{
return QStringLiteral( "tilesxyzdirectory" );
}
QString QgsXyzTilesDirectoryAlgorithm::displayName() const
{
return QObject::tr( "Generate XYZ tiles (Directory)" );
}
QStringList QgsXyzTilesDirectoryAlgorithm::tags() const
{
return QObject::tr( "tiles,xyz,tms,directory" ).split( ',' );
}
QString QgsXyzTilesDirectoryAlgorithm::shortHelpString() const
{
return QObject::tr( "Generates XYZ tiles of map canvas content and saves them as individual images in a directory." );
}
QgsXyzTilesDirectoryAlgorithm *QgsXyzTilesDirectoryAlgorithm::createInstance() const
{
return new QgsXyzTilesDirectoryAlgorithm();
}
void QgsXyzTilesDirectoryAlgorithm::initAlgorithm( const QVariantMap & )
{
createCommonParameters();
addParameter( new QgsProcessingParameterNumber( QStringLiteral( "TILE_WIDTH" ), QObject::tr( "Tile width" ), QgsProcessingParameterNumber::Integer, 256, false, 1, 4096 ) );
addParameter( new QgsProcessingParameterNumber( QStringLiteral( "TILE_HEIGHT" ), QObject::tr( "Tile height" ), QgsProcessingParameterNumber::Integer, 256, false, 1, 4096 ) );
addParameter( new QgsProcessingParameterBoolean( QStringLiteral( "TMS_CONVENTION" ), QObject::tr( "Use inverted tile Y axis (TMS convention)" ), false, true ) );
std::unique_ptr< QgsProcessingParameterString > titleParam = std::make_unique< QgsProcessingParameterString >( QStringLiteral( "HTML_TITLE" ), QObject::tr( "Leaflet HTML output title" ), QVariant(), false, true );
titleParam->setFlags( titleParam->flags() | QgsProcessingParameterDefinition::FlagAdvanced );
addParameter( titleParam.release() );
std::unique_ptr< QgsProcessingParameterString > attributionParam = std::make_unique< QgsProcessingParameterString >( QStringLiteral( "HTML_ATTRIBUTION" ), QObject::tr( "Leaflet HTML output attribution" ), QVariant(), false, true );
attributionParam->setFlags( attributionParam->flags() | QgsProcessingParameterDefinition::FlagAdvanced );
addParameter( attributionParam.release() );
std::unique_ptr< QgsProcessingParameterBoolean > osmParam = std::make_unique< QgsProcessingParameterBoolean >( QStringLiteral( "HTML_OSM" ), QObject::tr( "Include OpenStreetMap basemap in Leaflet HTML output" ), false, true );
osmParam->setFlags( osmParam->flags() | QgsProcessingParameterDefinition::FlagAdvanced );
addParameter( osmParam.release() );
addParameter( new QgsProcessingParameterFolderDestination( QStringLiteral( "OUTPUT_DIRECTORY" ), QObject::tr( "Output directory" ) ) );
addParameter( new QgsProcessingParameterFileDestination( QStringLiteral( "OUTPUT_HTML" ), QObject::tr( "Output html (Leaflet)" ), QObject::tr( "HTML files (*.html)" ), QVariant(), true ) );
}
QVariantMap QgsXyzTilesDirectoryAlgorithm::processAlgorithm( const QVariantMap &parameters, QgsProcessingContext &context, QgsProcessingFeedback *feedback )
{
const bool tms = parameterAsBoolean( parameters, QStringLiteral( "TMS_CONVENTION" ), context );
const QString title = parameterAsString( parameters, QStringLiteral( "HTML_TITLE" ), context );
const QString attribution = parameterAsString( parameters, QStringLiteral( "HTML_ATTRIBUTION" ), context );
const bool useOsm = parameterAsBoolean( parameters, QStringLiteral( "HTML_OSM" ), context );
QString outputDir = parameterAsString( parameters, QStringLiteral( "OUTPUT_DIRECTORY" ), context );
const QString outputHtml = parameterAsString( parameters, QStringLiteral( "OUTPUT_HTML" ), context );
mOutputDir = outputDir;
mTms = tms;
for ( int z = mMinZoom; z <= mMaxZoom; z++ )
{
if ( feedback->isCanceled() )
break;
mMetaTiles += getMetatiles( mWgs84Extent, z, mMetaTileSize );
}
for ( QgsMapLayer *layer : std::as_const( mLayers ) )
{
layer->moveToThread( QThread::currentThread() );
}
mTotalTiles = mMetaTiles.size();
QEventLoop loop;
// cppcheck-suppress danglingLifetime
mEventLoop = &loop;
startJobs();
loop.exec();
qDeleteAll( mLayers );
mLayers.clear();
QVariantMap results;
results.insert( QStringLiteral( "OUTPUT_DIRECTORY" ), outputDir );
if ( !outputHtml.isEmpty() )
{
QString osm = QStringLiteral(
"var osm_layer = L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png',"
"{minZoom: %1, maxZoom: %2, attribution: '&copy; <a href=\"https://www.openstreetmap.org/copyright\">OpenStreetMap</a> contributors'}).addTo(map);" )
.arg( mMinZoom ).arg( mMaxZoom );
QString addOsm = useOsm ? osm : QString();
QString tmsConvention = tms ? QStringLiteral( "true" ) : QStringLiteral( "false" );
QString attr = attribution.isEmpty() ? QStringLiteral( "Created by QGIS" ) : attribution;
QString tileSource = QStringLiteral( "'file:///%1/{z}/{x}/{y}.%2'" )
.arg( outputDir.replace( "\\", "/" ).toHtmlEscaped() ).arg( mTileFormat.toLower() );
QString html = QStringLiteral(
"<!DOCTYPE html><html><head><title>%1</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.9.3/dist/leaflet.css\""
"integrity=\"sha384-o/2yZuJZWGJ4s/adjxVW71R+EO/LyCwdQfP5UWSgX/w87iiTXuvDZaejd3TsN7mf\""
"crossorigin=\"\"/>"
"<script src=\"https://unpkg.com/leaflet@1.9.3/dist/leaflet.js\""
"integrity=\"sha384-okbbMvvx/qfQkmiQKfd5VifbKZ/W8p1qIsWvE1ROPUfHWsDcC8/BnHohF7vPg2T6\""
"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', {attributionControl: false}).setView([%2, %3], %4);"
"L.control.attribution({prefix: false}).addTo(map);"
"%5"
"var tilesource_layer = L.tileLayer(%6, {minZoom: %7, maxZoom: %8, tms: %9, attribution: '%10'}).addTo(map);"
"</script></body></html>"
)
.arg( title.isEmpty() ? QStringLiteral( "Leaflet preview" ) : title )
.arg( mWgs84Extent.center().y() )
.arg( mWgs84Extent.center().x() )
.arg( ( mMaxZoom + mMinZoom ) / 2 )
.arg( addOsm )
.arg( tileSource )
.arg( mMinZoom )
.arg( mMaxZoom )
.arg( tmsConvention )
.arg( attr );
QFile htmlFile( outputHtml );
if ( !htmlFile.open( QIODevice::WriteOnly | QIODevice::Truncate ) )
{
throw QgsProcessingException( QObject::tr( "Could not html file %1" ).arg( outputHtml ) );
}
QTextStream fout( &htmlFile );
#if QT_VERSION < QT_VERSION_CHECK(6, 0, 0)
fout.setCodec( "UTF-8" );
#endif
fout << html;
results.insert( QStringLiteral( "OUTPUT_HTML" ), outputHtml );
}
return results;
}
void QgsXyzTilesDirectoryAlgorithm::processMetaTile( QgsMapRendererSequentialJob *job )
{
MetaTile metaTile = mRendererJobs.value( job );
QImage img = job->renderedImage();
QMap<QPair<int, int>, Tile>::const_iterator it = metaTile.tiles.constBegin();
while ( it != metaTile.tiles.constEnd() )
{
QPair<int, int> tm = it.key();
Tile tile = it.value();
QImage tileImage = img.copy( mTileWidth * tm.first, mTileHeight * tm.second, mTileWidth, mTileHeight );
QDir tileDir( QStringLiteral( "%1/%2/%3" ).arg( mOutputDir ).arg( tile.z ).arg( tile.x ) );
tileDir.mkpath( tileDir.absolutePath() );
int y = tile.y;
if ( mTms )
{
y = tile2tms( y, tile.z );
}
tileImage.save( QStringLiteral( "%1/%2.%3" ).arg( tileDir.absolutePath() ).arg( y ).arg( mTileFormat.toLower() ), mTileFormat.toStdString().c_str(), mJpgQuality );
++it;
}
mRendererJobs.remove( job );
job->deleteLater();
mFeedback->setProgress( 100.0 * ( mProcessedTiles++ ) / mTotalTiles );
if ( mFeedback->isCanceled() )
{
while ( mRendererJobs.size() > 0 )
{
QgsMapRendererSequentialJob *j = mRendererJobs.firstKey();
j->cancel();
mRendererJobs.remove( j );
j->deleteLater();
}
mRendererJobs.clear();
mEventLoop->exit();
return;
}
if ( mMetaTiles.size() > 0 )
{
startJobs();
}
else if ( mMetaTiles.size() == 0 && mRendererJobs.size() == 0 )
{
mEventLoop->exit();
}
}
// Native XYZ tiles (MBTiles) algorithm
QString QgsXyzTilesMbtilesAlgorithm::name() const
{
return QStringLiteral( "tilesxyzmbtiles" );
}
QString QgsXyzTilesMbtilesAlgorithm::displayName() const
{
return QObject::tr( "Generate XYZ tiles (MBTiles)" );
}
QStringList QgsXyzTilesMbtilesAlgorithm::tags() const
{
return QObject::tr( "tiles,xyz,tms,mbtiles" ).split( ',' );
}
QString QgsXyzTilesMbtilesAlgorithm::shortHelpString() const
{
return QObject::tr( "Generates XYZ tiles of map canvas content and saves them as an MBTiles file." );
}
QgsXyzTilesMbtilesAlgorithm *QgsXyzTilesMbtilesAlgorithm::createInstance() const
{
return new QgsXyzTilesMbtilesAlgorithm();
}
void QgsXyzTilesMbtilesAlgorithm::initAlgorithm( const QVariantMap & )
{
createCommonParameters();
addParameter( new QgsProcessingParameterFileDestination( QStringLiteral( "OUTPUT_FILE" ), QObject::tr( "Output" ), QObject::tr( "MBTiles files (*.mbtiles *.MBTILES)" ) ) );
}
QVariantMap QgsXyzTilesMbtilesAlgorithm::processAlgorithm( const QVariantMap &parameters, QgsProcessingContext &context, QgsProcessingFeedback *feedback )
{
const QString outputFile = parameterAsString( parameters, QStringLiteral( "OUTPUT_FILE" ), context );
mMbtilesWriter = std::make_unique<QgsMbTiles>( outputFile );
if ( !mMbtilesWriter->create() )
{
throw QgsProcessingException( QObject::tr( "Failed to create MBTiles file %1" ).arg( outputFile ) );
}
mMbtilesWriter->setMetadataValue( "format", mTileFormat.toLower() );
mMbtilesWriter->setMetadataValue( "name", QFileInfo( outputFile ).baseName() );
mMbtilesWriter->setMetadataValue( "version", QStringLiteral( "1.1" ) );
mMbtilesWriter->setMetadataValue( "type", QStringLiteral( "overlay" ) );
mMbtilesWriter->setMetadataValue( "minzoom", QString::number( mMinZoom ) );
mMbtilesWriter->setMetadataValue( "maxzoom", QString::number( mMaxZoom ) );
QString boundsStr = QString( "%1,%2,%3,%4" )
.arg( mWgs84Extent.xMinimum() ).arg( mWgs84Extent.yMinimum() )
.arg( mWgs84Extent.xMaximum() ).arg( mWgs84Extent.yMaximum() );
mMbtilesWriter->setMetadataValue( "bounds", boundsStr );
for ( int z = mMinZoom; z <= mMaxZoom; z++ )
{
if ( feedback->isCanceled() )
break;
mMetaTiles += getMetatiles( mWgs84Extent, z, mMetaTileSize );
}
mTotalTiles = mMetaTiles.size();
QEventLoop loop;
// cppcheck-suppress danglingLifetime
mEventLoop = &loop;
startJobs();
loop.exec();
QVariantMap results;
results.insert( QStringLiteral( "OUTPUT_FILE" ), outputFile );
return results;
}
void QgsXyzTilesMbtilesAlgorithm::processMetaTile( QgsMapRendererSequentialJob *job )
{
MetaTile metaTile = mRendererJobs.value( job );
QImage img = job->renderedImage();
QMap<QPair<int, int>, Tile>::const_iterator it = metaTile.tiles.constBegin();
while ( it != metaTile.tiles.constEnd() )
{
QPair<int, int> tm = it.key();
Tile tile = it.value();
QImage tileImage = img.copy( mTileWidth * tm.first, mTileHeight * tm.second, mTileWidth, mTileHeight );
QByteArray ba;
QBuffer buffer( &ba );
buffer.open( QIODevice::WriteOnly );
tileImage.save( &buffer, mTileFormat.toStdString().c_str(), mJpgQuality );
mMbtilesWriter->setTileData( tile.z, tile.x, tile2tms( tile.y, tile.z ), ba );
++it;
}
mRendererJobs.remove( job );
job->deleteLater();
mFeedback->setProgress( 100.0 * ( mProcessedTiles++ ) / mTotalTiles );
if ( mFeedback->isCanceled() )
{
while ( mRendererJobs.size() > 0 )
{
QgsMapRendererSequentialJob *j = mRendererJobs.firstKey();
j->cancel();
mRendererJobs.remove( j );
j->deleteLater();
}
mRendererJobs.clear();
mEventLoop->exit();
return;
}
if ( mMetaTiles.size() > 0 )
{
startJobs();
}
else if ( mMetaTiles.size() == 0 && mRendererJobs.size() == 0 )
{
mEventLoop->exit();
}
}
///@endcond

View File

@ -0,0 +1,195 @@
/***************************************************************************
qgsalgorithmxyztiles.h
---------------------
begin : August 2023
copyright : (C) 2023 by Alexander Bruy
email : alexander dot bruy at gmail dot com
***************************************************************************/
/***************************************************************************
* *
* This program is free software; you can redistribute it and/or modify *
* it under the terms of the GNU General Public License as published by *
* the Free Software Foundation; either version 2 of the License, or *
* (at your option) any later version. *
* *
***************************************************************************/
#ifndef QGSALGORITHMXYZTILES_H
#define QGSALGORITHMXYZTILES_H
#define SIP_NO_FILE
#include "qgis_sip.h"
#include "qgsprocessingalgorithm.h"
#include "qgsmaprenderersequentialjob.h"
#include "qgsmbtiles.h"
///@cond PRIVATE
int tile2tms( const int y, const int zoom );
int lon2tileX( const double lon, const int z );
int lat2tileY( const double lat, const int z );
double tileX2lon( const int x, const int z );
double tileY2lat( const int y, const int z );
void extent2TileXY( const QgsRectangle extent, const int zoom, int &xMin, int &yMin, int &xMax, int &yMax );
struct Tile
{
Tile( const int x, const int y, const int z )
: x( x )
, y( y )
, z( z )
{}
int x;
int y;
int z;
};
struct MetaTile
{
MetaTile()
: rows( 0 )
, cols( 0 )
{}
void addTile( const int row, const int col, Tile tileToAdd )
{
tiles.insert( QPair<int, int>( row, col ), tileToAdd );
if ( row >= rows )
{
rows = row + 1;
}
if ( col >= cols )
{
cols = col + 1;
}
}
QgsRectangle extent()
{
const Tile first = tiles.first();
const Tile last = tiles.last();
return QgsRectangle( tileX2lon( first.x, first.z ), tileY2lat( last.y + 1, last.z ),
tileX2lon( last.x + 1, last.z ), tileY2lat( first.y, first.z ) );
}
QMap< QPair<int, int>, Tile > tiles;
int rows;
int cols;
};
QList< MetaTile > getMetatiles( const QgsRectangle extent, const int zoom, const int tileSize = 4 );
/**
* Base class for native XYZ tiles algorithms.
*/
class QgsXyzTilesBaseAlgorithm : public QgsProcessingAlgorithm
{
public:
QString group() const override;
QString groupId() const override;
Flags flags() const override;
protected:
/**
* Creates common parameters used in all algorithms
*/
void createCommonParameters();
bool prepareAlgorithm( const QVariantMap &parameters, QgsProcessingContext &context, QgsProcessingFeedback *feedback ) override;
void startJobs();
virtual void processMetaTile( QgsMapRendererSequentialJob *job ) = 0;
QgsRectangle mExtent;
QColor mBackgroundColor;
int mMinZoom = 12;
int mMaxZoom = 12;
int mDpi = 96;
bool mAntialias = true;
int mJpgQuality = 75;
int mMetaTileSize = 4;
int mThreadsNumber = 1;
int mTileWidth = 256;
int mTileHeight = 256;
QString mTileFormat;
QList< QgsMapLayer * > mLayers;
QgsCoordinateReferenceSystem mWgs84Crs;
QgsCoordinateReferenceSystem mMercatorCrs;
QgsCoordinateTransform mSrc2Wgs;
QgsCoordinateTransform mWgs2Mercator;
QgsRectangle mWgs84Extent;
QgsProcessingFeedback *mFeedback;
long long mTotalTiles = 0;
long long mProcessedTiles = 0;
QgsCoordinateTransformContext mTransformContext;
QEventLoop *mEventLoop;
QList< MetaTile > mMetaTiles;
QMap< QgsMapRendererSequentialJob *, MetaTile > mRendererJobs;
};
/**
* Native XYZ tiles (directory) algorithm.
*/
class QgsXyzTilesDirectoryAlgorithm : public QgsXyzTilesBaseAlgorithm
{
public:
QgsXyzTilesDirectoryAlgorithm() = default;
void initAlgorithm( const QVariantMap &configuration = QVariantMap() ) override;
QString name() const override;
QString displayName() const override;
QStringList tags() const override;
QString shortHelpString() const override;
QgsXyzTilesDirectoryAlgorithm *createInstance() const override SIP_FACTORY;
protected:
QVariantMap processAlgorithm( const QVariantMap &parameters,
QgsProcessingContext &context, QgsProcessingFeedback *feedback ) override;
void processMetaTile( QgsMapRendererSequentialJob *job ) override;
private:
bool mTms;
QString mOutputDir;
};
/**
* Native XYZ tiles (MBTiles) algorithm.
*/
class QgsXyzTilesMbtilesAlgorithm : public QgsXyzTilesBaseAlgorithm
{
public:
QgsXyzTilesMbtilesAlgorithm() = default;
void initAlgorithm( const QVariantMap &configuration = QVariantMap() ) override;
QString name() const override;
QString displayName() const override;
QStringList tags() const override;
QString shortHelpString() const override;
QgsXyzTilesMbtilesAlgorithm *createInstance() const override SIP_FACTORY;
protected:
QVariantMap processAlgorithm( const QVariantMap &parameters,
QgsProcessingContext &context, QgsProcessingFeedback *feedback ) override;
void processMetaTile( QgsMapRendererSequentialJob *job ) override;
private:
std::unique_ptr<QgsMbTiles> mMbtilesWriter;
};
///@endcond PRIVATE
#endif // QGSALGORITHMXYZTILES_H

View File

@ -221,6 +221,7 @@
#include "qgsalgorithmvectorize.h"
#include "qgsalgorithmwedgebuffers.h"
#include "qgsalgorithmwritevectortiles.h"
#include "qgsalgorithmxyztiles.h"
#include "qgsalgorithmzonalhistogram.h"
#include "qgsalgorithmzonalstatistics.h"
#include "qgsalgorithmzonalstatisticsfeaturebased.h"
@ -519,6 +520,8 @@ void QgsNativeAlgorithms::loadAlgorithms()
addAlgorithm( new QgsWedgeBuffersAlgorithm() );
addAlgorithm( new QgsWriteVectorTilesXyzAlgorithm() );
addAlgorithm( new QgsWriteVectorTilesMbtilesAlgorithm() );
addAlgorithm( new QgsXyzTilesDirectoryAlgorithm() );
addAlgorithm( new QgsXyzTilesMbtilesAlgorithm() );
addAlgorithm( new QgsZonalHistogramAlgorithm() );
addAlgorithm( new QgsZonalStatisticsAlgorithm() );
addAlgorithm( new QgsZonalStatisticsFeatureBasedAlgorithm() );