From 73a5a6adc195186525f5f742d0d0032dc0aa37b3 Mon Sep 17 00:00:00 2001 From: Marcel Dancak Date: Wed, 24 Apr 2019 12:20:15 +0200 Subject: [PATCH 01/10] Initial version of xyz tiles export (processing tool) --- .../plugins/processing/algs/qgis/TilesXYZ.py | 366 ++++++++++++++++++ 1 file changed, 366 insertions(+) create mode 100644 python/plugins/processing/algs/qgis/TilesXYZ.py diff --git a/python/plugins/processing/algs/qgis/TilesXYZ.py b/python/plugins/processing/algs/qgis/TilesXYZ.py new file mode 100644 index 00000000000..5a19dcafe62 --- /dev/null +++ b/python/plugins/processing/algs/qgis/TilesXYZ.py @@ -0,0 +1,366 @@ +# -*- coding: utf-8 -*- + +""" +*************************************************************************** + 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' + +# This will get replaced with a git SHA1 when you do a git archive + +__revision__ = '$Format:%H$' + +import os +import math +from uuid import uuid4 + +import ogr +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, + QgsProcessingOutputFile, + QgsProcessingParameterFileDestination, + QgsProcessingParameterFolderDestination, + QgsProject, + QgsGeometry, + QgsRectangle, + QgsMapSettings, + QgsCoordinateTransform, + QgsCoordinateReferenceSystem, + QgsMapRendererCustomPainterJob) +from processing.algs.qgis.QgisAlgorithm import QgisAlgorithm + + +# Math functions taken from https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames +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) + +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 DirectoryWriter: + def __init__(self, folder, tile_params): + self.folder = folder + self.format = tile_params.get('format', 'PNG') + self.quality = tile_params.get('quality', -1) + + def writeTile(self, tile, image): + directory = os.path.join(self.folder, str(tile.z), str(tile.x)) + os.makedirs(directory, exist_ok=True) + path = os.path.join(directory, '{}.{}'.format(tile.y, self.format.lower())) + image.save(path, self.format, self.quality) + return path + + def close(self): + return self.folder + + +class MBTilesWriter: + def __init__(self, filename, tile_params, extent, min_zoom, max_zoom): + base_dir = os.path.dirname(filename) + os.makedirs(base_dir, exist_ok=True) + self.filename = filename + self.extent = extent + self.tile_width = tile_params.get('width', 256) + self.tile_height = tile_params.get('height', 256) + tile_format = tile_params['format'] + if tile_format == 'JPG': + tile_format = 'JPEG' + options = ['QUALITY=%s' % tile_params.get('quality', 75)] + else: + options = ['ZLEVEL=8'] + driver = gdal.GetDriverByName('MBTiles') + ds = driver.Create(filename, 1, 1, 1, options=['TILE_FORMAT=%s' % tile_format] + options) + ds = None + sqlite_driver = ogr.GetDriverByName('SQLite') + ds = sqlite_driver.Open(filename, 1) + ds.ExecuteSQL("INSERT INTO metadata(name, value) VALUES ('{}', '{}');".format('minzoom', min_zoom)) + ds.ExecuteSQL("INSERT INTO metadata(name, value) VALUES ('{}', '{}');".format('maxzoom', max_zoom)) + # will be set properly after writing all tiles + ds.ExecuteSQL("INSERT INTO metadata(name, value) VALUES ('{}', '');".format('bounds')) + ds = None + self._zoom = None + + def _initZoomLayer(self, first_tile): + first_tile_extent = first_tile.extent() + sqlite_driver = ogr.GetDriverByName('SQLite') + ds = sqlite_driver.Open(self.filename, 1) + zoom_extent = [ + first_tile_extent[0], + # extend with height of 1 tile, but do not exceed -89.98 to stay in valid range (gdal) + max(-89.98, self.extent[1] - (first_tile_extent[3] - first_tile_extent[1])), + # extend with width of 1 tile, do not exceed 180 + min(180, self.extent[2] + (first_tile_extent[2] - first_tile_extent[0])), + first_tile_extent[3] + ] + bounds = ','.join(map(str, zoom_extent)) + ds.ExecuteSQL("UPDATE metadata SET value='{}' WHERE name='bounds'".format(bounds)) + ds = None + + self._zoomDs = gdal.OpenEx(self.filename, 1, open_options=['ZOOM_LEVEL=%s' % first_tile.z]) + self._first_tile = first_tile + self._zoom = first_tile.z + + def writeTile(self, tile, image): + if tile.z != self._zoom: + self._initZoomLayer(tile) + + 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._zoomDs.WriteRaster(xoff, yoff, self.tile_width, self.tile_height, data) + + def close(self): + self._zoomDs = None + sqlite_driver = ogr.GetDriverByName('SQLite') + ds = sqlite_driver.Open(self.filename, 1) + bounds = ','.join(map(str, self.extent)) + ds.ExecuteSQL("UPDATE metadata SET value='{}' WHERE name='bounds'".format(bounds)) + ds = None + return self.filename + + +class TilesXYZ(QgisAlgorithm): + NAME = 'NAME' + EXTENT = 'EXTENT' + ZOOM_MIN = 'ZOOM_MIN' + ZOOM_MAX = 'ZOOM_MAX' + TILE_FORMAT = 'TILE_FORMAT' + DPI = 'DPI' + OUTPUT_FORMAT = 'OUTPUT_FORMAT' + OUTPUT_DIRECTORY = 'OUTPUT_DIRECTORY' + + def group(self): + return self.tr('Raster tools') + + def groupId(self): + return 'rastertools' + + def initAlgorithm(self, config=None): + self.addParameter(QgsProcessingParameterString(self.NAME, self.tr('Name'), defaultValue='Test')) + self.addParameter(QgsProcessingParameterFolderDestination(self.OUTPUT_DIRECTORY, self.tr('Output directory'))) + + self.addParameter(QgsProcessingParameterExtent(self.EXTENT, self.tr('Extent'))) + self.addParameter(QgsProcessingParameterNumber(self.ZOOM_MIN, + self.tr('Minimum zoom'), + defaultValue=12)) + self.addParameter(QgsProcessingParameterNumber(self.ZOOM_MAX, + self.tr('Maximum zoom'), + defaultValue=12)) + self.addParameter(QgsProcessingParameterNumber(self.DPI, self.tr('DPI'), defaultValue=96)) + + self.formats = ['PNG', 'JPG'] + self.addParameter(QgsProcessingParameterEnum(self.TILE_FORMAT, + self.tr('Tile format'), + self.formats, + defaultValue=0)) + self.outputs = ['Directory', 'MBTiles'] + self.addParameter(QgsProcessingParameterEnum(self.OUTPUT_FORMAT, + self.tr('Output format'), + self.outputs, + defaultValue=0)) + + + def name(self): + return 'tilesxyz' + + def displayName(self): + return self.tr('Generate XYZ tiles') + + def processAlgorithm(self, parameters, context, feedback): + feedback.setProgress(1) + + name = self.parameterAsString(parameters, self.NAME, context) + output_dir = self.parameterAsString(parameters, self.OUTPUT_DIRECTORY, context) + extent = self.parameterAsExtent(parameters, self.EXTENT, context) + min_zoom = self.parameterAsInt(parameters, self.ZOOM_MIN, context) + max_zoom = self.parameterAsInt(parameters, self.ZOOM_MAX, context) + dpi = self.parameterAsInt(parameters, self.DPI, context) + tile_format = self.formats[self.parameterAsEnum(parameters, self.TILE_FORMAT, context)] + output_format = self.outputs[self.parameterAsEnum(parameters, self.OUTPUT_FORMAT, context)] + tile_width = 256 + tile_height = 256 + + project = QgsProject.instance() + visible_layers = [item.layer() for item in project.layerTreeRoot().findLayers() if item.isVisible()] + layers = [l for l in project.layerTreeRoot().layerOrder() if l in visible_layers] + + wgs_crs = QgsCoordinateReferenceSystem('EPSG:4326') + dest_crs = QgsCoordinateReferenceSystem('EPSG:3857') + + src_to_wgs = QgsCoordinateTransform(project.crs(), wgs_crs, context.transformContext()) + wgs_to_dest = QgsCoordinateTransform(wgs_crs, dest_crs, context.transformContext()) + + settings = QgsMapSettings() + settings.setOutputImageFormat(QImage.Format_ARGB32_Premultiplied) + settings.setDestinationCrs(dest_crs) + settings.setLayers(layers) + settings.setOutputDpi(dpi) + + wgs_extent = src_to_wgs.transform(extent) + wgs_extent = [wgs_extent.xMinimum(), wgs_extent.yMinimum(), wgs_extent.xMaximum(), wgs_extent.yMaximum()] + + metatiles_by_zoom = {} + metatiles_count = 0 + for zoom in range(min_zoom, max_zoom + 1): + metatiles = get_metatiles(wgs_extent, zoom, 4) + metatiles_by_zoom[zoom] = metatiles + metatiles_count += len(metatiles) + + lab_buffer_px = 100 + progress = 0 + + tile_params = { + 'format': tile_format, + 'quality': 75, + 'width': tile_width, + 'height': tile_height + } + if output_format == 'Directory': + writer = DirectoryWriter(os.path.join(output_dir, name), tile_params) + else: + filename = os.path.join(output_dir, '%s.mbtiles' % name) + writer = MBTilesWriter(filename, tile_params, wgs_extent, min_zoom, max_zoom) + + for zoom in range(min_zoom, max_zoom + 1): + feedback.pushConsoleInfo('Generating tiles for zoom level: %s' % zoom) + + for i, metatile in enumerate(metatiles_by_zoom[zoom]): + size = QSize(tile_width * metatile.rows(), tile_height * metatile.columns()) + extent = QgsRectangle(*metatile.extent()) + # TODO: transformation of bounding points? + settings.setExtent(wgs_to_dest.transform(extent)) + settings.setOutputSize(size) + + label_area = QgsRectangle(settings.extent()) + lab_buffer = label_area.width() * (lab_buffer_px / size.width()) + label_area.set( + label_area.xMinimum() + lab_buffer, + label_area.yMinimum() + lab_buffer, + label_area.xMaximum() - lab_buffer, + label_area.yMaximum() - lab_buffer + ) + settings.setLabelBoundaryGeometry(QgsGeometry.fromRect(label_area)) + + image = QImage(size, QImage.Format_ARGB32_Premultiplied) + image.fill(Qt.transparent) + dpm = settings.outputDpi() / 25.4 * 1000 + image.setDotsPerMeterX(dpm) + image.setDotsPerMeterY(dpm) + painter = QPainter(image) + job = QgsMapRendererCustomPainterJob(settings, painter) + job.renderSynchronously() + painter.end() + + # For analysing metatiles (labels, etc.) + metatile_dir = os.path.join(output_dir, name, str(zoom)) + os.makedirs(metatile_dir, exist_ok=True) + image.save(os.path.join(metatile_dir, 'metatile_%s.png' % i)) + + progress += 1 + feedback.setProgress(100 * (progress / metatiles_count)) + for r, c, tile in metatile.tiles: + tile_img = image.copy(tile_width * r, tile_height * c, tile_width, tile_height) + writer.writeTile(tile, tile_img) + + output = writer.close() + return {'OUTPUT': output} + + 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) From 4ab9bb617584b510524cb4afb35cac33019334b1 Mon Sep 17 00:00:00 2001 From: Marcel Dancak Date: Wed, 24 Apr 2019 13:11:36 +0200 Subject: [PATCH 02/10] Registration of TilesXYZ algorithm --- python/plugins/processing/algs/qgis/QgisAlgorithmProvider.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/python/plugins/processing/algs/qgis/QgisAlgorithmProvider.py b/python/plugins/processing/algs/qgis/QgisAlgorithmProvider.py index 32ddde1ebaa..8a6f5b31ab0 100644 --- a/python/plugins/processing/algs/qgis/QgisAlgorithmProvider.py +++ b/python/plugins/processing/algs/qgis/QgisAlgorithmProvider.py @@ -134,6 +134,7 @@ from .SpatialJoinSummary import SpatialJoinSummary from .StatisticsByCategories import StatisticsByCategories from .SumLines import SumLines from .TextToFloat import TextToFloat +from .TilesXYZ import TilesXYZ from .TinInterpolation import TinInterpolation from .TopoColors import TopoColor from .TruncateTable import TruncateTable @@ -244,6 +245,7 @@ class QgisAlgorithmProvider(QgsProcessingProvider): StatisticsByCategories(), SumLines(), TextToFloat(), + TilesXYZ(), TinInterpolation(), TopoColor(), TruncateTable(), From efdd52cb2f7d7e85acb43f937fa9558fd16813b7 Mon Sep 17 00:00:00 2001 From: Marcel Dancak Date: Wed, 24 Apr 2019 15:23:56 +0200 Subject: [PATCH 03/10] Commented out code for debugging (saving of metatiles), fixed python formatting --- python/plugins/processing/algs/qgis/TilesXYZ.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/python/plugins/processing/algs/qgis/TilesXYZ.py b/python/plugins/processing/algs/qgis/TilesXYZ.py index 5a19dcafe62..9ec41b42c62 100644 --- a/python/plugins/processing/algs/qgis/TilesXYZ.py +++ b/python/plugins/processing/algs/qgis/TilesXYZ.py @@ -52,7 +52,7 @@ from qgis.core import (QgsProcessingException, from processing.algs.qgis.QgisAlgorithm import QgisAlgorithm -# Math functions taken from https://wiki.openstreetmap.org/wiki/Slippy_map_tilenames +# 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 @@ -60,6 +60,7 @@ def deg2num(lat_deg, lon_deg, zoom): ytile = int((1.0 - math.log(math.tan(lat_rad) + (1 / math.cos(lat_rad))) / math.pi) / 2.0 * n) return (xtile, ytile) + def num2deg(xtile, ytile, zoom): n = 2.0 ** zoom lon_deg = xtile / n * 360.0 - 180.0 @@ -251,7 +252,6 @@ class TilesXYZ(QgisAlgorithm): self.outputs, defaultValue=0)) - def name(self): return 'tilesxyz' @@ -344,9 +344,9 @@ class TilesXYZ(QgisAlgorithm): painter.end() # For analysing metatiles (labels, etc.) - metatile_dir = os.path.join(output_dir, name, str(zoom)) - os.makedirs(metatile_dir, exist_ok=True) - image.save(os.path.join(metatile_dir, 'metatile_%s.png' % i)) + # metatile_dir = os.path.join(output_dir, name, str(zoom)) + # os.makedirs(metatile_dir, exist_ok=True) + # image.save(os.path.join(metatile_dir, 'metatile_%s.png' % i)) progress += 1 feedback.setProgress(100 * (progress / metatiles_count)) From 83f35617f3bdce6e44464467b1b399b18aaf9265 Mon Sep 17 00:00:00 2001 From: Marcel Dancak Date: Thu, 25 Apr 2019 09:43:33 +0200 Subject: [PATCH 04/10] MBTilesWriter can write tiles in any order, proper transforming of extents --- .../plugins/processing/algs/qgis/TilesXYZ.py | 27 ++++++++++--------- 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/python/plugins/processing/algs/qgis/TilesXYZ.py b/python/plugins/processing/algs/qgis/TilesXYZ.py index 9ec41b42c62..a796e7286c9 100644 --- a/python/plugins/processing/algs/qgis/TilesXYZ.py +++ b/python/plugins/processing/algs/qgis/TilesXYZ.py @@ -163,29 +163,33 @@ class MBTilesWriter: ds = None self._zoom = None - def _initZoomLayer(self, first_tile): + def _initZoomLayer(self, zoom): + 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() - sqlite_driver = ogr.GetDriverByName('SQLite') - ds = sqlite_driver.Open(self.filename, 1) + last_tile_extent = last_tile.extent() zoom_extent = [ first_tile_extent[0], - # extend with height of 1 tile, but do not exceed -89.98 to stay in valid range (gdal) - max(-89.98, self.extent[1] - (first_tile_extent[3] - first_tile_extent[1])), - # extend with width of 1 tile, do not exceed 180 - min(180, self.extent[2] + (first_tile_extent[2] - first_tile_extent[0])), + last_tile_extent[1], + last_tile_extent[2], first_tile_extent[3] ] + + sqlite_driver = ogr.GetDriverByName('SQLite') + ds = sqlite_driver.Open(self.filename, 1) bounds = ','.join(map(str, zoom_extent)) ds.ExecuteSQL("UPDATE metadata SET value='{}' WHERE name='bounds'".format(bounds)) ds = None self._zoomDs = gdal.OpenEx(self.filename, 1, open_options=['ZOOM_LEVEL=%s' % first_tile.z]) self._first_tile = first_tile - self._zoom = first_tile.z + self._zoom = zoom def writeTile(self, tile, image): if tile.z != self._zoom: - self._initZoomLayer(tile) + self._initZoomLayer(tile.z) data = QByteArray() buff = QBuffer(data) @@ -288,7 +292,7 @@ class TilesXYZ(QgisAlgorithm): settings.setLayers(layers) settings.setOutputDpi(dpi) - wgs_extent = src_to_wgs.transform(extent) + wgs_extent = src_to_wgs.transformBoundingBox(extent) wgs_extent = [wgs_extent.xMinimum(), wgs_extent.yMinimum(), wgs_extent.xMaximum(), wgs_extent.yMaximum()] metatiles_by_zoom = {} @@ -319,8 +323,7 @@ class TilesXYZ(QgisAlgorithm): for i, metatile in enumerate(metatiles_by_zoom[zoom]): size = QSize(tile_width * metatile.rows(), tile_height * metatile.columns()) extent = QgsRectangle(*metatile.extent()) - # TODO: transformation of bounding points? - settings.setExtent(wgs_to_dest.transform(extent)) + settings.setExtent(wgs_to_dest.transformBoundingBox(extent)) settings.setOutputSize(size) label_area = QgsRectangle(settings.extent()) From e0d6694a129931dedf64ee34988c854b8c1f58f0 Mon Sep 17 00:00:00 2001 From: Marcel Dancak Date: Thu, 25 Apr 2019 12:15:52 +0200 Subject: [PATCH 05/10] Adjusted min/max/default values of parameters, moved some code into 'prepareAlgorithm' --- .../plugins/processing/algs/qgis/TilesXYZ.py | 38 ++++++++++++------- 1 file changed, 25 insertions(+), 13 deletions(-) diff --git a/python/plugins/processing/algs/qgis/TilesXYZ.py b/python/plugins/processing/algs/qgis/TilesXYZ.py index a796e7286c9..0500095a8c1 100644 --- a/python/plugins/processing/algs/qgis/TilesXYZ.py +++ b/python/plugins/processing/algs/qgis/TilesXYZ.py @@ -42,7 +42,6 @@ from qgis.core import (QgsProcessingException, QgsProcessingOutputFile, QgsProcessingParameterFileDestination, QgsProcessingParameterFolderDestination, - QgsProject, QgsGeometry, QgsRectangle, QgsMapSettings, @@ -233,18 +232,25 @@ class TilesXYZ(QgisAlgorithm): return 'rastertools' def initAlgorithm(self, config=None): - self.addParameter(QgsProcessingParameterString(self.NAME, self.tr('Name'), defaultValue='Test')) - self.addParameter(QgsProcessingParameterFolderDestination(self.OUTPUT_DIRECTORY, self.tr('Output directory'))) - + self.addParameter(QgsProcessingParameterString(self.NAME, + self.tr('Name'), + defaultValue='Tiles')) 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'), defaultValue=96)) - + self.addParameter(QgsProcessingParameterNumber(self.DPI, + self.tr('DPI'), + minValue=48, + maxValue=600, + defaultValue=96)) self.formats = ['PNG', 'JPG'] self.addParameter(QgsProcessingParameterEnum(self.TILE_FORMAT, self.tr('Tile format'), @@ -255,6 +261,8 @@ class TilesXYZ(QgisAlgorithm): self.tr('Output format'), self.outputs, defaultValue=0)) + self.addParameter(QgsProcessingParameterFolderDestination(self.OUTPUT_DIRECTORY, + self.tr('Output directory'))) def name(self): return 'tilesxyz' @@ -262,6 +270,12 @@ class TilesXYZ(QgisAlgorithm): def displayName(self): return self.tr('Generate XYZ tiles') + 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 processAlgorithm(self, parameters, context, feedback): feedback.setProgress(1) @@ -276,20 +290,17 @@ class TilesXYZ(QgisAlgorithm): tile_width = 256 tile_height = 256 - project = QgsProject.instance() - visible_layers = [item.layer() for item in project.layerTreeRoot().findLayers() if item.isVisible()] - layers = [l for l in project.layerTreeRoot().layerOrder() if l in visible_layers] - wgs_crs = QgsCoordinateReferenceSystem('EPSG:4326') dest_crs = QgsCoordinateReferenceSystem('EPSG:3857') + project = context.project() src_to_wgs = QgsCoordinateTransform(project.crs(), wgs_crs, context.transformContext()) wgs_to_dest = QgsCoordinateTransform(wgs_crs, dest_crs, context.transformContext()) settings = QgsMapSettings() settings.setOutputImageFormat(QImage.Format_ARGB32_Premultiplied) settings.setDestinationCrs(dest_crs) - settings.setLayers(layers) + settings.setLayers(self.layers) settings.setOutputDpi(dpi) wgs_extent = src_to_wgs.transformBoundingBox(extent) @@ -351,12 +362,13 @@ class TilesXYZ(QgisAlgorithm): # os.makedirs(metatile_dir, exist_ok=True) # image.save(os.path.join(metatile_dir, 'metatile_%s.png' % i)) - progress += 1 - feedback.setProgress(100 * (progress / metatiles_count)) for r, c, tile in metatile.tiles: tile_img = image.copy(tile_width * r, tile_height * c, tile_width, tile_height) writer.writeTile(tile, tile_img) + progress += 1 + feedback.setProgress(100 * (progress / metatiles_count)) + output = writer.close() return {'OUTPUT': output} From 0bb701a64996d2a64b0e93e4872865b660d3825b Mon Sep 17 00:00:00 2001 From: Marcel Dancak Date: Thu, 25 Apr 2019 14:09:41 +0200 Subject: [PATCH 06/10] Added help text --- python/plugins/processing/algs/help/qgis.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/python/plugins/processing/algs/help/qgis.yaml b/python/plugins/processing/algs/help/qgis.yaml index 27e62942006..b0ba6c4d12b 100644 --- a/python/plugins/processing/algs/help/qgis.yaml +++ b/python/plugins/processing/algs/help/qgis.yaml @@ -509,6 +509,13 @@ qgis:sumlinelengths: > 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:tilesxyz: > + This algorithm generates raster XYZ tiles of map canvas content. + + Tile images can be saved as individual images in directory structure, or as single file in MBTiles format. + + Tile size is fixed to 256x256. + qgis:topologicalcoloring: > This algorithm assigns a color index to polygon features in such a way that no adjacent polygons share the same color index, whilst minimizing the number of colors required. From ee63cffd88e45ad1c22ef638518ce9378edc04e4 Mon Sep 17 00:00:00 2001 From: Martin Dobias Date: Thu, 25 Apr 2019 21:40:31 +0200 Subject: [PATCH 07/10] [processing] Add 'project' to test definition and 'directory' output test - for algorithms that produce directory output, it is possible to test that directory contents are exactly the same (recursively) - added possibility to have a project file loaded before an algorithm is run - documented the new additions (+ few existing ones) --- .../processing/tests/AlgorithmsTestBase.py | 15 ++++++++++++++- python/plugins/processing/tests/README.md | 19 +++++++++++++++++++ python/testing/__init__.py | 15 +++++++++++++++ 3 files changed, 48 insertions(+), 1 deletion(-) diff --git a/python/plugins/processing/tests/AlgorithmsTestBase.py b/python/plugins/processing/tests/AlgorithmsTestBase.py index 62d0df7f8a0..78d39491e57 100644 --- a/python/plugins/processing/tests/AlgorithmsTestBase.py +++ b/python/plugins/processing/tests/AlgorithmsTestBase.py @@ -86,7 +86,12 @@ class AlgorithmsTest(object): :param defs: A python dict containing a test algorithm definition """ self.vector_layer_params = {} - QgsProject.instance().removeAllMapLayers() + QgsProject.instance().clear() + + if 'project' in defs: + full_project_path = os.path.join(processingTestDataPath(), defs['project']) + project_read_success = QgsProject.instance().read(full_project_path) + self.assertTrue(project_read_success, 'Failed to load project file: ' + defs['project']) if 'project_crs' in defs: QgsProject.instance().setCrs(QgsCoordinateReferenceSystem(defs['project_crs'])) @@ -212,6 +217,9 @@ class AlgorithmsTest(object): basename = 'raster.tif' filepath = os.path.join(outdir, basename) return filepath + elif param['type'] == 'directory': + outdir = tempfile.mkdtemp() + return outdir raise KeyError("Unknown type '{}' specified for parameter".format(param['type'])) @@ -350,6 +358,11 @@ class AlgorithmsTest(object): result_filepath = results[id] self.assertFilesEqual(expected_filepath, result_filepath) + elif 'directory' == expected_result['type']: + expected_dirpath = self.filepath_from_param(expected_result) + result_dirpath = results[id] + + self.assertDirectoriesEqual(expected_dirpath, result_dirpath) elif 'regex' == expected_result['type']: with open(results[id], 'r') as file: data = file.read() diff --git a/python/plugins/processing/tests/README.md b/python/plugins/processing/tests/README.md index db46027f42e..7b884feb8ef 100644 --- a/python/plugins/processing/tests/README.md +++ b/python/plugins/processing/tests/README.md @@ -194,6 +194,25 @@ OUTPUT: - 'Feature Count: 6' ``` +#### Directories + +You can compare the content of an output directory by en expected result reference directory + +```yaml +OUTPUT_DIR: + name: expected/tiles_xyz/test_1 + type: directory +``` + +### Algorithm Context + +There are few more definitions that can modify context of the algorithm - these can be specified at top level of test: + +- `project` - will load a specified QGIS project file before running the algorithm. If not specified, algorithm will run with empty project +- `project_crs` - overrides the default project CRS - e.g. `EPSG:27700` +- `ellipsoid` - overrides the default project ellipsoid used for measurements - e.g. `GRS80` + + Running tests locally ------------------ ```bash diff --git a/python/testing/__init__.py b/python/testing/__init__.py index 4f648db2b49..fbe5e1c9b68 100644 --- a/python/testing/__init__.py +++ b/python/testing/__init__.py @@ -29,6 +29,7 @@ import os import sys import difflib import functools +import filecmp from qgis.PyQt.QtCore import QVariant from qgis.core import QgsApplication, QgsFeatureRequest, NULL @@ -196,6 +197,20 @@ class TestCase(_TestCase): diff = list(diff) self.assertEqual(0, len(diff), ''.join(diff)) + def assertDirectoriesEqual(self, dirpath_expected, dirpath_result): + """ Checks whether both directories have the same content (recursively) and raises an assertion error if not. """ + dc = filecmp.dircmp(dirpath_expected, dirpath_result) + dc.report_full_closure() + + def _check_dirs_equal_recursive(dcmp): + self.assertEqual(dcmp.left_only, []) + self.assertEqual(dcmp.right_only, []) + self.assertEqual(dcmp.diff_files, []) + for sub_dcmp in dcmp.subdirs.values(): + _check_dirs_equal_recursive(sub_dcmp) + + _check_dirs_equal_recursive(dc) + def assertGeometriesEqual(self, geom0, geom1, geom0_id='geometry 1', geom1_id='geometry 2', precision=14, topo_equal_check=False): self.checkGeometriesEqual(geom0, geom1, geom0_id, geom1_id, use_asserts=True, precision=precision, topo_equal_check=topo_equal_check) From 285d4e0da052fa1df67e4dce11fa8fc0e91444ca Mon Sep 17 00:00:00 2001 From: Martin Dobias Date: Thu, 25 Apr 2019 22:10:37 +0200 Subject: [PATCH 08/10] Remove NAME parameter, use OUTPUT_FILE instead For output to directory, OUTPUT_DIRECTORY destination parameter is used. For output to MBTiles file, OUTPUT_FILE destimation parameter is used. --- .../plugins/processing/algs/qgis/TilesXYZ.py | 38 +++++++++++-------- 1 file changed, 23 insertions(+), 15 deletions(-) diff --git a/python/plugins/processing/algs/qgis/TilesXYZ.py b/python/plugins/processing/algs/qgis/TilesXYZ.py index 0500095a8c1..777be02f0a4 100644 --- a/python/plugins/processing/algs/qgis/TilesXYZ.py +++ b/python/plugins/processing/algs/qgis/TilesXYZ.py @@ -133,7 +133,7 @@ class DirectoryWriter: return path def close(self): - return self.folder + pass class MBTilesWriter: @@ -212,11 +212,9 @@ class MBTilesWriter: bounds = ','.join(map(str, self.extent)) ds.ExecuteSQL("UPDATE metadata SET value='{}' WHERE name='bounds'".format(bounds)) ds = None - return self.filename class TilesXYZ(QgisAlgorithm): - NAME = 'NAME' EXTENT = 'EXTENT' ZOOM_MIN = 'ZOOM_MIN' ZOOM_MAX = 'ZOOM_MAX' @@ -224,6 +222,7 @@ class TilesXYZ(QgisAlgorithm): DPI = 'DPI' OUTPUT_FORMAT = 'OUTPUT_FORMAT' OUTPUT_DIRECTORY = 'OUTPUT_DIRECTORY' + OUTPUT_FILE = 'OUTPUT_FILE' def group(self): return self.tr('Raster tools') @@ -232,9 +231,6 @@ class TilesXYZ(QgisAlgorithm): return 'rastertools' def initAlgorithm(self, config=None): - self.addParameter(QgsProcessingParameterString(self.NAME, - self.tr('Name'), - defaultValue='Tiles')) self.addParameter(QgsProcessingParameterExtent(self.EXTENT, self.tr('Extent'))) self.addParameter(QgsProcessingParameterNumber(self.ZOOM_MIN, self.tr('Minimum zoom'), @@ -262,7 +258,12 @@ class TilesXYZ(QgisAlgorithm): self.outputs, defaultValue=0)) self.addParameter(QgsProcessingParameterFolderDestination(self.OUTPUT_DIRECTORY, - self.tr('Output directory'))) + self.tr('Output directory'), + optional=True)) + self.addParameter(QgsProcessingParameterFileDestination(self.OUTPUT_FILE, + self.tr('Output file (for MBTiles)'), + self.tr('MBTiles files (*.mbtiles)'), + optional=True)) def name(self): return 'tilesxyz' @@ -279,14 +280,16 @@ class TilesXYZ(QgisAlgorithm): def processAlgorithm(self, parameters, context, feedback): feedback.setProgress(1) - name = self.parameterAsString(parameters, self.NAME, context) - output_dir = self.parameterAsString(parameters, self.OUTPUT_DIRECTORY, context) extent = self.parameterAsExtent(parameters, self.EXTENT, context) min_zoom = self.parameterAsInt(parameters, self.ZOOM_MIN, context) max_zoom = self.parameterAsInt(parameters, self.ZOOM_MAX, context) dpi = self.parameterAsInt(parameters, self.DPI, context) tile_format = self.formats[self.parameterAsEnum(parameters, self.TILE_FORMAT, context)] output_format = self.outputs[self.parameterAsEnum(parameters, self.OUTPUT_FORMAT, context)] + if output_format == 'Directory': + output_dir = self.parameterAsString(parameters, self.OUTPUT_DIRECTORY, context) + else: # MBTiles + output_file = self.parameterAsString(parameters, self.OUTPUT_FILE, context) tile_width = 256 tile_height = 256 @@ -323,10 +326,9 @@ class TilesXYZ(QgisAlgorithm): 'height': tile_height } if output_format == 'Directory': - writer = DirectoryWriter(os.path.join(output_dir, name), tile_params) + writer = DirectoryWriter(output_dir, tile_params) else: - filename = os.path.join(output_dir, '%s.mbtiles' % name) - writer = MBTilesWriter(filename, tile_params, wgs_extent, min_zoom, max_zoom) + writer = MBTilesWriter(output_file, tile_params, wgs_extent, min_zoom, max_zoom) for zoom in range(min_zoom, max_zoom + 1): feedback.pushConsoleInfo('Generating tiles for zoom level: %s' % zoom) @@ -358,7 +360,7 @@ class TilesXYZ(QgisAlgorithm): painter.end() # For analysing metatiles (labels, etc.) - # metatile_dir = os.path.join(output_dir, name, str(zoom)) + # 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)) @@ -369,8 +371,14 @@ class TilesXYZ(QgisAlgorithm): progress += 1 feedback.setProgress(100 * (progress / metatiles_count)) - output = writer.close() - return {'OUTPUT': output} + writer.close() + + results = {} + if output_format == 'Directory': + results['OUTPUT_DIRECTORY'] = output_dir + else: # MBTiles + results['OUTPUT_FILE'] = output_file + return results def checkParameterValues(self, parameters, context): min_zoom = self.parameterAsInt(parameters, self.ZOOM_MIN, context) From fdb6a2b79a506a344c6660344b7a291488f25fbe Mon Sep 17 00:00:00 2001 From: Martin Dobias Date: Thu, 25 Apr 2019 22:14:08 +0200 Subject: [PATCH 09/10] Add test for XYZ Tiles algorithm --- .../testdata/expected/xyztiles/1/0/0.png | Bin 0 -> 6763 bytes .../testdata/expected/xyztiles/2/0/1.png | Bin 0 -> 11715 bytes .../testdata/expected/xyztiles/2/1/1.png | Bin 0 -> 4520 bytes .../testdata/expected/xyztiles/3/1/2.png | Bin 0 -> 7493 bytes .../testdata/expected/xyztiles/3/1/3.png | Bin 0 -> 17798 bytes .../testdata/expected/xyztiles/3/2/2.png | Bin 0 -> 3151 bytes .../testdata/expected/xyztiles/3/2/3.png | Bin 0 -> 5604 bytes .../tests/testdata/qgis_algorithm_tests.yaml | 16 + tests/testdata/xyztiles.qgs | 744 ++++++++++++++++++ 9 files changed, 760 insertions(+) create mode 100644 python/plugins/processing/tests/testdata/expected/xyztiles/1/0/0.png create mode 100644 python/plugins/processing/tests/testdata/expected/xyztiles/2/0/1.png create mode 100644 python/plugins/processing/tests/testdata/expected/xyztiles/2/1/1.png create mode 100644 python/plugins/processing/tests/testdata/expected/xyztiles/3/1/2.png create mode 100644 python/plugins/processing/tests/testdata/expected/xyztiles/3/1/3.png create mode 100644 python/plugins/processing/tests/testdata/expected/xyztiles/3/2/2.png create mode 100644 python/plugins/processing/tests/testdata/expected/xyztiles/3/2/3.png create mode 100644 tests/testdata/xyztiles.qgs diff --git a/python/plugins/processing/tests/testdata/expected/xyztiles/1/0/0.png b/python/plugins/processing/tests/testdata/expected/xyztiles/1/0/0.png new file mode 100644 index 0000000000000000000000000000000000000000..1f5a65a7fb121ee65270c89da11c4237ef7d2770 GIT binary patch literal 6763 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 E2igY2u>b%7 literal 0 HcmV?d00001 diff --git a/python/plugins/processing/tests/testdata/expected/xyztiles/2/0/1.png b/python/plugins/processing/tests/testdata/expected/xyztiles/2/0/1.png new file mode 100644 index 0000000000000000000000000000000000000000..dc244915e530a8580ecf37304cd723582ea52632 GIT binary patch literal 11715 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

PGtim!E&PIBENk9 E4-k0uK>z>% literal 0 HcmV?d00001 diff --git a/python/plugins/processing/tests/testdata/expected/xyztiles/2/1/1.png b/python/plugins/processing/tests/testdata/expected/xyztiles/2/1/1.png new file mode 100644 index 0000000000000000000000000000000000000000..fb3e28c5545f3f052d08f4cc06d60be1eef914cf GIT binary patch literal 4520 zcmeI0`8U-6_rPB>X3Px95KV(>P+7`&r-+&|wi@M4h$%!Vl%?^WQHjh9AuX~rL-r+# z-m-6#H4?H+$&xK&$=I`wedarz^ZhG6=l#R;+QB)xe5isn{b5!06Xjd3{WE}>m~q5*_j#}T=I3C9rW;KbnR_i z5Zuv8IK@I58WPIUW42??7rLz8(i>v#DePsuvv!;Eai4u=-XF&+A#J3SPF7F**qyTR z@F=j$_cbwjrz9n{1Eb!g9gH`0ZVEpTJga9BFX3?y5z8B!;n!F^MpyKX=?U0^8O>g| z6fQ39Rd28a2y#g**v9tnZ*U9}js(mg5E*eK@HD|czB`9`1g+yz z#4`T*Nxq#|Y3`gBB?;ZR_v8V29nZ)mF5As}oe@gE6PR`b7(Q}4#Y!c7sRo-uY=8Q! zxU}?qKtR?1uOZzovL|fZw4c#S_HCZkihQ}BQRUHtot&PQH@R4|0(~&uMQdvQY@~ow z$gCHEXFSGt>R!d7V*9Y|=|NTF#oQ`XdXrvL_mY@rBfKw2yg znYBaxmb-iO+}s?eW|jq(I8}P+(!K2<%}pmYWn&=F5}Ykkiz_KBSK>?5b76_Pe)yAp z_M;J8lv(8DUGcNq*Y@E7kN}EB1H4quyeoTmaX)|X_n}KAW8?j;`^96oj`9!xWe8!2 zIV>_0dLA5Vk7hCt4qn|$UM`9li73~*w#olf{A?p*X-;w0Nf0b=7ZsJ>@`Y7U&a(?=Vl;neyiabDxtw}W9*mP z{+Hd>;T_#%E885FQ=yWYz?V?Ef1eQml7x=XK*pONT>2g-WKPxkFQ8~HE}55XY?`}c zyJ4EtioJUIL`OfKCpc7@R+NZa5+o-O` z-L35y40u`XPasL0jSz-9VeR3F9i6w$? zMT(+Qu(b{So|HaKc`*ED488;K70kx&dY6-=?K4MnWtwZlF@LE8@$!(9cf0QG3KU1de;{uiOs=?%tGI0a<*!mD%USG zhA;Q#%H77*gVCppbvTzDcp@PdXMSqrC3FQ5-#Z@eVPAhwFKAsm2_98LCVr*yJS%2? z@Z*of#rmeQ_pRq;#p{f(&4|;uoAxgCMKs1}<4#d_M4UhhxY4lyfqb^XP)#iUu^gHG zxY>pds%R@5Vrmx!)gZ0B!W;Ep*5LT|y@GG;!Ea{<7Ty$>B*kd8%M(l*N8!&a@Hk+R z{P+$;3zu#Y@lffvwr4Dgzmj2=os88k=`kgd%2+txwB_Y{rWW_#dJZl=`~sd~K}glr z26G=a %h*kMU#er^Sezg?$+ocyWG%J`g>1=&RW9{WwA_Y?R@enE z65LAL#cn=A2(H!zO!;oka`TNi8`&-lz19T*0e$_ZLoQ;sY+#dv0Wew-38_`_Z#`ct zgeTP;1E*X6eXiAwZ?p=!sDl3pSKS?!2g@h*^gl(b?gb@*7l0oXOB$4-h)@0^69>}V zAXA-S;16ss@ZQM$_oo*6 z1m3zAgn7tIepI7H+o(Cr zGdIwYdH1G0Oh2zb2R*;BWD!q+VVi=Z$?J2g`(n205U;5_C57d+2AoFZB|Q(IIR1L2 zj3e11UKr0v2N}WO_bXS@XL5(61!p#6hI3)mJIRy>tH?c+$2brO!v75(Zh5fKZ@Hr? z|FPqi&JG@}{PcD2yPqe)z31DA>RfsF{R>0W5fJG(al%?e`{RON`RcH3;- za_hQ+sF$H+GWq7bC+I+F68XZ|^g{&0 z+L$zA(TcfhEN^Q15vhc`v$LnisV!pIUzpGopt+OY+gw@Y9?|1EIxlXZXXHST+_Ri` za4psOnxt6Y`-+N9cOH9HNV%?d0@;m*j<6p&|y;{0bcuX6lt~Y>KmTpm8X1(~KFNn--jZ@}1 zD+<#lK{Jp*Gv~{O>uP;fd649Gvm;7!bg0aGxbE-<8iy9PL&&gC*rfv){}8<8^gk5K zuT;d<<G0*s7QXIP4};Ew8U!0UMX@lwe4o67$*9h!-WCh zQ6*&d$@yd-ruk8oWwYqO#5&Fv)!)RsxI)=?*ez~#tG+)7Dn1rtez2P{$doS!=Y~R}*?CR>uPOcjjvK|VKijSAO?e2d5gTm$Gat4`F`(vP1w0s9w_B=NSZw!>4B+D;18R@T7a+t|qbBfjxHb@Nhq zj+pQT?dXRGP!{I^5E}vq*m8B=NN+VfP~Pe41ou6J2YkDcp^xYnK;YU;olV2qV?w1U z^e+X7gBTLPmh_^)$p|EntQ|az;aJfD@9ef}wB|L0OYybd&r=qN&0|1~rrzikSjOSqHS?cf{usoEF zy#RohegAh*hM=W(008xkih``3zs0dRmWRb3AL%+@H080Pz&%0PzErQ0VGsk%85~k03qR+Y>MH)f(3+L-ZQ+9rU2nXWdaNg zmi$u)2SVt;s2>TzC~U&g`mE3J9e_|P0e}UmJu4G`hNoV92)B8z@$u7}KXCx8aA^pT zgb{+O^)|R%^BKN;Zsxzz{|)@VYxduA{YNSPpNKTMz{syWG>TEn+?-1(k@|B{5fexz zv}1c`XLW-zw%THalyURRpumTot9XdL{r9xAwBeCdRw)q>IXUa$e02sEF^gHZNGRS( zf>gLGK%56e1zcKM+TKc%fyu|dyfp{FTaTBUe`WI7GV$?EL%Zkjm;gUu-57z*4GqIt z0*=7$5N07EeHM*uCM>MSb|%{Gr6rD$5%yCzxwYU64Y82B2FD=zHK#I8YMuiha;Ci% z?bhJiy5O?`a(=%Pvu>|O_kB9IAEjFGQt;IDH2n*F+O&@!v3vbHy1Kg7eQ%By8C~Zp z&!{RyL_|~%_S$!*dd+uB6tc7b?O1;CHzhKIp2x&sRL3I664Ga~BxfzEyF7AnE4VY> zH#yvHMB&@rT^pQ>QuO%NA{9peqYI5q>4qFru^hZA4>?BH@VBU7Q zZ=b>lUt{JYZ^b+RwzbzXN z^Ah{m~=-0cv6Jduxnk7ZVy{jeiWWqfX;}XScEJnYDSVB+^K*kn>nHC{R)VaP z^(=nFF5U=8^U>06Q>F1u_^O_fk<;E|>e8bl#mZhoP7fFpOmwjCl!|=(;}GOxdT4)p z^sV^_&Rh}3nX+pWsOjg|ToTf6eLA)EqSvT78DQgrA@vEqP}|p~4Nl+MMGr=#<{UEZ zJ@BcxmynrTjcF0ZZcNJMwmBb|g14ynF1-J8g(2lBd3=*XZhG+jB%p!W3T8CbIK zYarrUy9MRBlMx92?$t-P{UfK0`7dEJhspHC83L}oAD=Ya%kRY{B+v!e?{DB4b8``~ zY!oqk7#{i>-nJG;M~n55bjl%5M$Q6T!ImQ}-kOTAbY&OYPq=)t-7P2F_q@AbjzRn_ zNl=0QjZ50(KQD}HJkp`5GYeX*?P_a{%R`AYhl`fpHg9^GA8NAmgZd+gSv4@azPgL$`B4M$3>FLO9*rUkm?iwDKMvS^*VnX0&xaZu+~|4G%| z*}3Xg-e(Gu_`ThB?d=XnU#e8p81nYvGLEd%3 z^)p?#=Oo61XPVxif7I7!a40Bk)A!U7_eka@~U)@W#`QF6?Pf4Xez=tOg60b)p$R?$l)Y>rM+x%C^KEz>>j!df4aIK*V50jYpLcIRAjA&7TU*8sWKc|&*~Ah8ux zaOzy|RxINCYfIMJp~Ysq5h7VQ%GLut3rl#<`t69wq9ls?e3o*dl;3(+$aUfCjZ^%t zM-k7>pT=T4znPVCzQEpQY&j%8nT8?eXVwUzx>3_X9+9bivH0X2kBpx;zYYB|Uw?w*w-PL6FcCe9LL}uR7AQjace-D{-wBP^4VCcQ8mNH6 zk5q1v?011>S+6r~sl_LJ?PKRGYIF_3*ucA#l$4!mndMi=j@x_ByIJk-`xfsqozSjZ zZR1+c&XpU9^;BHqA}`FI5z8v{hRhjbog?S(~7n^SwK?1&Cx3|geX#d_IzyHHt?7HgSp30}$%|3?NEt%xlANBWk zI(chAfeU6lv9Am8URM#mSk0~4l@b~12 zFnmi~3NUsolKonwEAoIfLHje>1||%HPSm4EdI|&r`3ATk$0nw%--?$s{KIQ<=eeAnLFcRRMuKPDM5=_i+X#G$tvqZI)@Y_Hm{`zkMPfFx#anV*Nv-ZD# zmkHg8d0v)=nV}7R5uKKIr?{~#CFOcv9vqc{V{kTH`6+9X`CG9h8zn`1eqKlpsLx@^ zmN$C8a>sARu=9_WH?~uRVeMC;=72^0d@RAd56363F>(GvYQ+7MLtZ12j*KX0Tx?!I zyAbeD4L3pEP^05mBl!m4&5_-aHJ-xcw$q!#9}cBxy&<<`+V5s6YqR+%gg>t_Vu5wG z{qxCD!6_h>-J zw`I>FFRx}u+zKoL-ovgId$~%I$UL{(Lv#G{OPG3Y+-XfUf|CoPBu! zx@651%ak50Xuu-!0K}AOjj9^0Pc~Ug8dW|xhWm7SWAT-f{t}f@6kQB0>FL%%p!g<# zs2Su_{a|8ZdPmzNS-;`*HOn9&9hYWF=`CZ@3CSxh&!S%hg^a@i9{rtD944Q`nnRCJ z`bU>kmj}e}P2F2xlX02YRiJIU!M=Qf?lsrqUm%TuKv_?%_Dg&0mq3t?#b1e_wTB2y zmW5kJrB506iv zf*M5GDZ+iE&EqFGmy0J?xSS4&>Q^xxOD*c_D><Isq>xfxe*qWQV4ZaS}=_)?$Ln zM~~*c-QCg0n{}Q6mPSydO&MF>uH=I`%N}y(1ABf^na48zjz1BEjqNP6ls??OS~po> zmLA}O`86K5SCq0gTDi!h--unv2iO+$JA_js4$4;#oL0Zay6LK+JfDz&H~3)dsF%HK z2AlF{J9kH%e3B1`l$w2W%Uo6r5m6Z3r0~G3gvmqPM$_1>(`(xlIdJ`rJDksUkl6`S-=I;^{5WI|QS%;AXFPXVDS4%`)Jb%@t>w251hdQB}>rHBiQ{j29<~lQiTy5bv|uxB>gij2lDBFq#V9V zHC7eQJwcKw6ISjIQ4Ev2Qk5bCQLY8{VgTC2^yB@QE{c=MWW;R!*^z0mKovpVxV&O@ z;~>vA?;OcU<-foft03J9hS%wPc-Sc0A+`afP#4a*e*A-@)AH2VNt0@mMi;I5c3RDt zU-XEbR|(ES&b6h^v6!NR-Igww>%$XT8N_}R~e=I=3p@Ulro3QG+i9uf=S;wG)%%$BkCAzQJyZu*S-2hgg*;e zoL<&8!Pvdcj$x;)DOp`t@GHLUVIMslK{RFrx_r`w?u*ilA#Yom%;D~JMkjiS1996(;gB>y(EcJ z$&GVm%C2Vko{3uVDd0&66q%=liW#Gx(7eTk_=&@VS>6Y+YKSl9JZfLUX*@HDCCy}R zViA9vkT9#|ZFm-?UfbJ6Xj|zpwM&T8qer2PHy{f}NJBafu0$i7rBOpU4JtpFkazZy z+(XjA&D*coZa=0EzSS^S8l0zK)g#k$weGM1z! zO%K;ys$3%yxUulzgpq>W`Mu0+&Zx);q$@JTdYY``N9_~c_lc?Kq{aENopNI$Dif=U>B<*MDy6e9aboaW5j&3+j4?1z_d}iJX%Cv=z~Wcj zXtxs=Jo}m7kLwBc#ncqiQi*SpmE;K;3zc1^HiVASfCw$Y@aO>@AylpvJlcF2_%iN6j@1@oZ?tp}L%PbedY>~6N zg8^UaQcFGVWi1K;=d<$X!^ejmNwX!S*d-Pd+r|eZ-?ad|a z+jEm-o?0p`I+xaj96%ds`))wa!W{$rE%$z6jMQp6w3e?+b{$#VIl>_kT%;#38aj6> z5)^P|yU(G)q&K&!pGXdK2YWGxP9(c0cI4o>)1H@;wtjgt-z5NCI-F+Z02EJ+A~ASp zo(m+vQNx`ot{=q_I?FwuN6VdopO~cbBsvRJG7nMFLJO6F(5h#vZ=te}%J^jU(p&~r z)Vo08pZdG0%C|`INHpt+wLce=GH~{*PZq1sK9bvNXh)P1?4(A3x<-P7W#FULbi5Z( zx4e)5Fpr9Bx5@@r`kR!xFr6teQ~%(_o6)OBkFC}sQ+wPHyG@A;(lVjC+MXOUI=J$h z3)VXS+23{4mXbo$&A%Ge+I)DnqFp=+tu-b(B1=PCKmSFi=-poGa-=6%#Y4?XstXI0 zUW(Y@uDb!(bV7d`Dv;xP>Nq%91~V3R3Jq(o zdW2Y$yV?2!BKO~%ZYEOJZwo$K8slmZx4__$8$}pt#as>)hxSaT_Gm%2tg4*g71c|n zTt1n9cjlC0f1rvlfJX{};m}H=*1)bz!TxL1GnMO@hkPEsn-ZKLz+)kCRLDdu4Pk*Z z4cQI@ez8Ux%oB-wc`CsnoC(74Hm2W42V#>A0uo*4wrA`uZz_lvoKuqX1J6sWufl75 z(Zpzy9dwsCr@Xn97YNt0k<&ncJWj9-VPxHr*j{4xb}htj)=!@ZBFIG`XNq_rE#hL9 zZ)1FTerjf5-4nbT?41t+?-#elYsaiE7;d~TJm|CQ8-(7j9*}>5!=hev2%tZ{^mOZ4 zUJoeE8B69)27~uMNFW)fYNJ+nC$h)d{Abb}*Xa)&=(TI4H;`y+wuQXZY6r~E|Dphu M*P04da#oT5183%7`~Uy| literal 0 HcmV?d00001 diff --git a/python/plugins/processing/tests/testdata/expected/xyztiles/3/1/3.png b/python/plugins/processing/tests/testdata/expected/xyztiles/3/1/3.png new file mode 100644 index 0000000000000000000000000000000000000000..8fc2550273a158201d7654c1c683e0bd623f1ada GIT binary patch literal 17798 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

BdyTD%@s;wy8%VPgp|p0fY8=QW#K$N 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 literal 0 HcmV?d00001 diff --git a/python/plugins/processing/tests/testdata/expected/xyztiles/3/2/2.png b/python/plugins/processing/tests/testdata/expected/xyztiles/3/2/2.png new file mode 100644 index 0000000000000000000000000000000000000000..c030145059cc498dd58b135deeb02fff29178566 GIT binary patch literal 3151 zcmeH~`#;ltAIHD@`j*LQ^0;y5FcnH+x;nTp?5?CJT$$#$smPo~iZEMBk0Nf59--@E zRF|+Zk<((P+)i6JNlH#jlEd2OIBd4vyRJXue)PlV@&5esem_5t*Zc5zf9`pEx@qp% zwF3YE%`>M@o(BM!@(Kgs+m$9Lq4J8-sKuZ5PXqw1&c6<wA;z=_fI)>Q2M`KTfkV2cWIiyo%le|DOHssux=_4bWLR_qnly#=|GW|wv5S?i zO$jeeZ)%eYTZenJMCjz?fqxWJ0Fb8+Y7%y!N3oW;Pq8-P)G?pPft@*4_<&ecfP=~0 zNZsWygvE!^-ej zbdk^^C`%dpBB&{XolR=nn_c*bYTe2q&g4sc#e%VQ4Bjq2aqbi|?MdrudQt@K#8qby zKCTK5O{x&DNjteF2XT%h?+d=j{Xaw^l&ZFg6^gx;@QmL7wXN>`MAFl=>%@Wef_}&9GT)MkR5D}W( zl1BPAy*&R~n>#$2y`Cm*q_ftGXEh*1O9u4aj9j;%o5kW&E$Lp0Rt+t!0-;b?Pm)Re zjfa^-rG@Dk^3Dx~z%czV?8wuvXh?{p*sGO^r)!(5ExoX=@37sRzK8q5&B?F%pE#2) zvi2MeUS!d?zW#IN%p~6kf9CV4nf{XiMS3EnA2bMV_*3I(?E$Vdm7BlUZK2knc%BbB z%Qi&oTRpt`$fT5uv-~jr3aY?0yKubpTI@Du63lJGVIN}GB%H=1mC4Dz-&tEG`YTJFT3rXSK;NxTY>N=Q^Zs>}lcfv0ARYq9PhR?0TGEDw}i=#JV zef7oHwZi+5;_XX*|p!P={ZIG?F_vtSq@9~P=6<(z_c5#!%V%1zLOw1v4z{ z^NY>R2zJjMxar~-_}f6BQGfe{(?rXmCF9!4QN82{r&FvKu$=p;PO@G)W^-t2MF17}o7LeAhq_zok*DnYlO6e|_>;Z}}2KRkW6?NZnM zth*}^d0!_CdD(~Mx`gk=QDy*jzY_x|Sfvq3A?AfZ45Pe;lMtOIvF zZ}FAQz0h8l*U7s}8~MiyW^c4#IOg<{HI-DNbkQ#DC>Fucq^hVbVsq@o=wh#zi6dIh zU{ez-IE6jV5y?*Bd&nJc5JeAxNTgPPRK~tO+7RIr5fc-Wyn5GJ!o9uXSW0?6wf6S| zPK0=Z#47oJ$*tOQ!DwUD{k_OP7Av|Uk^gK;8Oya~@S`Dw9~^wChnW$_r)({k*vYB` zn;F%?txZ!`2?7>&yQ&a<%DeT=@41?5p8(}r+i~M=NA2g4iG0)e!j5XlTe`*mm>s+(3TDR< zFBRwWGI;Gwx;}TAa9h)uaqp) cVv$h&N6GQ{lHNk)4*@vi>UolJJml8@05lEvjQ{`u literal 0 HcmV?d00001 diff --git a/python/plugins/processing/tests/testdata/expected/xyztiles/3/2/3.png b/python/plugins/processing/tests/testdata/expected/xyztiles/3/2/3.png new file mode 100644 index 0000000000000000000000000000000000000000..31d5ea48180af19353cdc5eeb1bbdc08c68826b9 GIT binary patch literal 5604 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 literal 0 HcmV?d00001 diff --git a/python/plugins/processing/tests/testdata/qgis_algorithm_tests.yaml b/python/plugins/processing/tests/testdata/qgis_algorithm_tests.yaml index 4105e6d5245..b71d9298cb5 100755 --- a/python/plugins/processing/tests/testdata/qgis_algorithm_tests.yaml +++ b/python/plugins/processing/tests/testdata/qgis_algorithm_tests.yaml @@ -7524,5 +7524,21 @@ tests: name: expected/join_to_nearest_no_matches.gml type: vector + - name: Generate XYZ tiles + algorithm: qgis:tilesxyz + project: ../../../../../tests/testdata/xyztiles.qgs + project_crs: EPSG:3857 + params: + EXTENT: -12535000,-9883000,3360000,5349000 [EPSG:3857] + ZOOM_MIN: 1 + ZOOM_MAX: 3 + TILE_FORMAT: 0 # png + OUTPUT_FORMAT: 0 # directory + + results: + OUTPUT_DIRECTORY: + type: directory + name: expected/xyztiles + # See ../README.md for a description of the file format diff --git a/tests/testdata/xyztiles.qgs b/tests/testdata/xyztiles.qgs new file mode 100644 index 00000000000..2aa9d433b4a --- /dev/null +++ b/tests/testdata/xyztiles.qgs @@ -0,0 +1,744 @@ + + + + + + + + + + +proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs + 3857 + 3857 + EPSG:3857 + WGS 84 / Pseudo-Mercator + merc + WGS84 + false + + + + + + + + + + + + polys20151123133114244 + lines20151123133101198 + + + + + + + + + + + meters + + -12244360.81901275180280209 + 3360185.32420947728678584 + -10173775.61578787304461002 + 5348862.70644816849380732 + + 0 + + + +proj=merc +a=6378137 +b=6378137 +lat_ts=0.0 +lon_0=0.0 +x_0=0.0 +y_0=0 +k=1.0 +units=m +nadgrids=@null +wktext +no_defs + 3857 + 3857 + EPSG:3857 + WGS 84 / Pseudo-Mercator + merc + WGS84 + false + + + 0 + + + + + + + + + + + + + + + + + -117.62319839219053108 + 23.20820580488508966 + -82.32264950769274492 + 46.18290982947509349 + + lines20151123133101198 + ./lines.shp + + + + Roads + + + +proj=longlat +datum=WGS84 +no_defs + 3452 + 4326 + EPSG:4326 + WGS 84 + longlat + WGS84 + true + + + + + + + + + + + + + + + + 0 + 0 + + + + + false + + + + + ogr + + + + + + + + + + + 1 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + ../src/quickgui/app/qgis-data + + 0 + generatedlayout + + + + COALESCE( "Name", '<NULL>' ) + + + + + -118.92286230599032137 + 24.50786971868489061 + -83.79001199101509201 + 46.72617265077044379 + + polys20151123133114244 + ./polys.shp + + + + Land + + + +proj=longlat +datum=WGS84 +no_defs + 3452 + 4326 + EPSG:4326 + WGS 84 + longlat + WGS84 + true + + + + + + + + + + + + + + + + 0 + 0 + + + + + false + + + + + ogr + + + + + + + + + + + 1 + 1 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + 0 + 1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + ../src/quickgui/app/qgis-data + + 0 + generatedlayout + + + + COALESCE( "Name", '<NULL>' ) + + + + + + + + + + 255 + + + + + 1 + true + + + + 20 + 1 + to vertex and segment + + to_vertex_and_segment + to_vertex_and_segment + to_vertex_and_segment + + + enabled + enabled + enabled + + + lines20151123133101198 + points20151123133104693 + polys20151123133114244 + + + 20.000000 + 20.000000 + 20.000000 + + + 1 + 1 + 1 + + current_layer + + + 255 + 255 + 255 + 255 + 0 + 255 + 255 + + + + + + false + + + + + + WGS84 + + + m2 + meters + + + 50 + 16 + 30 + false + 0 + false + false + true + 0 + + + false + + + true + 2 + MU + + + 3452 + +proj=longlat +datum=WGS84 +no_defs + EPSG:4326 + 1 + + + + + + + + + + + + + + + None + false + + + + + + conditions unknown + 90 + + + + 1 + + 8 + + false + + false + + + false + + + + + + + + false + + + + + false + + 5000 + + + + false + + + + + + + + + + + + + + + + + + + + + + + + + + + 2000-01-01T00:00:00 + + + + From e6ff7e00ef681d67be87ddda329350dbe962700f Mon Sep 17 00:00:00 2001 From: Martin Dobias Date: Thu, 25 Apr 2019 23:24:38 +0200 Subject: [PATCH 10/10] Raise an exception if the necessary parameter was not specified --- python/plugins/processing/algs/qgis/TilesXYZ.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/python/plugins/processing/algs/qgis/TilesXYZ.py b/python/plugins/processing/algs/qgis/TilesXYZ.py index 777be02f0a4..aa19e6dbf22 100644 --- a/python/plugins/processing/algs/qgis/TilesXYZ.py +++ b/python/plugins/processing/algs/qgis/TilesXYZ.py @@ -288,8 +288,12 @@ class TilesXYZ(QgisAlgorithm): output_format = self.outputs[self.parameterAsEnum(parameters, self.OUTPUT_FORMAT, context)] if output_format == 'Directory': output_dir = self.parameterAsString(parameters, self.OUTPUT_DIRECTORY, context) + if not output_dir: + raise QgsProcessingException(self.tr('You need to specify output directory.')) else: # MBTiles output_file = self.parameterAsString(parameters, self.OUTPUT_FILE, context) + if not output_file: + raise QgsProcessingException(self.tr('You need to specify output filename.')) tile_width = 256 tile_height = 256