From a7f9018e11be823c090999434667268fee39fe03 Mon Sep 17 00:00:00 2001 From: volaya Date: Thu, 17 Nov 2016 12:06:54 +0100 Subject: [PATCH] [processing] added native raster calculator algorithm Conflicts: python/plugins/processing/algs/qgis/QGISAlgorithmProvider.py --- python/plugins/processing/algs/help/qgis.yaml | 15 + .../algs/qgis/QGISAlgorithmProvider.py | 3 +- .../processing/algs/qgis/RasterCalculator.py | 164 ++++++++ .../algs/qgis/ui/ExpressionWidget.ui | 372 ++++++++++++++++++ .../algs/qgis/ui/RasterCalculatorWidgets.py | 135 +++++++ .../processing/modeler/ModelerAlgorithm.py | 2 +- 6 files changed, 689 insertions(+), 2 deletions(-) create mode 100644 python/plugins/processing/algs/qgis/RasterCalculator.py create mode 100644 python/plugins/processing/algs/qgis/ui/ExpressionWidget.ui create mode 100644 python/plugins/processing/algs/qgis/ui/RasterCalculatorWidgets.py diff --git a/python/plugins/processing/algs/help/qgis.yaml b/python/plugins/processing/algs/help/qgis.yaml index dc28eefe9aa..d17fa199dc5 100644 --- a/python/plugins/processing/algs/help/qgis.yaml +++ b/python/plugins/processing/algs/help/qgis.yaml @@ -552,3 +552,18 @@ qgis:voronoipolygons: > qgis:zonalstatistics: +qgis:rastercalculator: > + This algorithm allows to perform algebraic operations using raster layers. + + The resulting layer will have its values computed according to an expression. The expression can contain numerical values, operators and references to any of the layers in the current project. The following functions are also supported: + + - sin(), cos(), tan(), atan2(), ln(), log10() + + The extent and cellsize can be defined by the user. If the extent is not specified, the minimum extent that covers the input layers will be used. If the cellsize is not specified, the minimum cellsize of all input layers will be used. + + The cellsize is assumed to be the same in both X and Y axes. + + Layers are refered by their name as displayed in the layer list and the number of the band to use (based on 1), using the pattern 'layer_name@band number'. For instance, the first band from a layer named DEM will be referred as DEM@1. + + When using the calculator in the batch interface or from the console, the files to use have to be specified. The corresponding layers are refered using the base name of the file (without the full path). For instance, is using a layer at path/to/my/rasterfile.tif, the first band of that layer will be refered as rasterfile.tif@1. + diff --git a/python/plugins/processing/algs/qgis/QGISAlgorithmProvider.py b/python/plugins/processing/algs/qgis/QGISAlgorithmProvider.py index ee9f2ea4ca1..9a0fc5e11fc 100644 --- a/python/plugins/processing/algs/qgis/QGISAlgorithmProvider.py +++ b/python/plugins/processing/algs/qgis/QGISAlgorithmProvider.py @@ -178,6 +178,7 @@ from .ExtractSpecificNodes import ExtractSpecificNodes from .GeometryByExpression import GeometryByExpression from .SnapGeometries import SnapGeometriesToLayer from .PoleOfInaccessibility import PoleOfInaccessibility +from .RasterCalculator import RasterCalculator from .CreateAttributeIndex import CreateAttributeIndex from .DropGeometry import DropGeometry from .BasicStatistics import BasicStatisticsForField @@ -245,7 +246,7 @@ class QGISAlgorithmProvider(AlgorithmProvider): RemoveNullGeometry(), ExtractByExpression(), ExtendLines(), ExtractSpecificNodes(), GeometryByExpression(), SnapGeometriesToLayer(), PoleOfInaccessibility(), CreateAttributeIndex(), DropGeometry(), - BasicStatisticsForField() + BasicStatisticsForField(), RasterCalculator() ] if hasMatplotlib: diff --git a/python/plugins/processing/algs/qgis/RasterCalculator.py b/python/plugins/processing/algs/qgis/RasterCalculator.py new file mode 100644 index 00000000000..b19dfbe7db8 --- /dev/null +++ b/python/plugins/processing/algs/qgis/RasterCalculator.py @@ -0,0 +1,164 @@ +# -*- coding: utf-8 -*- + +""" +*************************************************************************** + RasterLayerCalculator.py + --------------------- + Date : November 2016 + Copyright : (C) 2016 by Victor Olaya + Email : volayaf 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. * +* * +*************************************************************************** +""" +from processing.modeler.ModelerAlgorithm import ValueFromInput, ValueFromOutput +import os + + +__author__ = 'Victor Olaya' +__date__ = 'November 2016' +__copyright__ = '(C) 2016, Victor Olaya' + +# This will get replaced with a git SHA1 when you do a git archive + +__revision__ = '$Format:%H$' + +import math +from processing.core.GeoAlgorithm import GeoAlgorithm +from processing.core.parameters import ParameterMultipleInput, ParameterExtent, ParameterString, ParameterRaster, ParameterNumber +from processing.core.outputs import OutputRaster +from processing.tools import dataobjects +from processing.algs.gdal.GdalUtils import GdalUtils +from qgis.core import QgsRectangle +from qgis.analysis import QgsRasterCalculator, QgsRasterCalculatorEntry +from processing.core.GeoAlgorithmExecutionException import GeoAlgorithmExecutionException +from processing.algs.qgis.ui.RasterCalculatorWidgets import LayersListWidgetWrapper, ExpressionWidgetWrapper + +class RasterCalculator(GeoAlgorithm): + + LAYERS = 'LAYERS' + EXTENT = 'EXTENT' + CELLSIZE = 'CELLSIZE' + EXPRESSION = 'EXPRESSION' + OUTPUT = 'OUTPUT' + + def defineCharacteristics(self): + self.name, self.i18n_name = self.trAlgorithm('Raster calculator') + self.group, self.i18n_group = self.trAlgorithm('Raster') + + self.addParameter(ParameterMultipleInput(self.LAYERS, + self.tr('Input layers'), + datatype=dataobjects.TYPE_RASTER, + optional=True, + metadata = {'widget_wrapper': LayersListWidgetWrapper})) + class ParameterExpression(ParameterString): + def evaluateForModeler(self, value, model): + #print value + for i in list(model.inputs.values()): + param = i.param + if isinstance(param, ParameterRaster): + new = "%s@" % os.path.basename(param.value) + old = "%s@" % param.name + value = value.replace(old, new) + + for alg in list(model.algs.values()): + for out in alg.algorithm.outputs: + print out, out.value + if isinstance(out, OutputRaster): + if out.value: + new = "%s@" % os.path.basename(out.value) + old = "%s:%s@" % (alg.name, out.name) + value = value.replace(old, new) + return value + + self.addParameter(ParameterExpression(self.EXPRESSION, self.tr('Expression'), + multiline=True, + metadata = {'widget_wrapper': ExpressionWidgetWrapper})) + self.addParameter(ParameterNumber(self.CELLSIZE, + self.tr('Cellsize (use 0 or empty to set it automatically)'), + minValue=0.0, default=0.0, optional=True)) + self.addParameter(ParameterExtent(self.EXTENT, + self.tr('Output extent'), + optional=True)) + self.addOutput(OutputRaster(self.OUTPUT, self.tr('Output'))) + + + def processAlgorithm(self, progress): + expression = self.getParameterValue(self.EXPRESSION) + layersValue = self.getParameterValue(self.LAYERS) + layersDict = {} + if layersValue: + layers = [dataobjects.getObjectFromUri(f) for f in layersValue.split(";")] + layersDict = {os.path.basename(lyr.source()): lyr for lyr in layers} + + for lyr in dataobjects.getRasterLayers(): + name = lyr.name() + if (name + "@") in expression: + layersDict[name] = lyr + + entries = [] + for name, lyr in layersDict.iteritems(): + for n in xrange(lyr.bandCount()): + entry = QgsRasterCalculatorEntry() + entry.ref = '%s@%i' % (name, n + 1) + entry.raster = lyr + entry.bandNumber = n + 1 + entries.append(entry) + + output = self.getOutputValue(self.OUTPUT) + extentValue = self.getParameterValue(self.EXTENT) + + if extentValue: + extent = extentValue.split(',') + bbox = QgsRectangle(float(extent[0]), float(extent[2]), + float(extent[1]), float(extent[3])) + else: + if layersDict: + bbox = layersDict.values()[0].extent() + for lyr in layersDict.values(): + bbox.combineExtentWith(lyr.extent()) + else: + raise GeoAlgorithmExecutionException(self.tr("No layers selected")) + def _cellsize(layer): + return (layer.extent().xMaximum() - layer.extent().xMinimum()) / layer.width() + cellsize = self.getParameterValue(self.CELLSIZE) or min([_cellsize(lyr) for lyr in layersDict.values()]) + width = math.floor((bbox.xMaximum() - bbox.xMinimum()) / cellsize) + height = math.floor((bbox.yMaximum() - bbox.yMinimum()) / cellsize) + driverName = GdalUtils.getFormatShortNameFromFilename(output) + calc = QgsRasterCalculator(expression, + output, + driverName, + bbox, + width, + height, + entries) + + res = calc.processCalculation() + if res == QgsRasterCalculator.ParserError: + raise GeoAlgorithmExecutionException(self.tr("Error parsing formula")) + + def processBeforeAddingToModeler(self, algorithm, model): + values = [] + expression = algorithm.params[self.EXPRESSION] + for i in list(model.inputs.values()): + param = i.param + if isinstance(param, ParameterRaster) and "%s@" % param.name in expression: + values.append(ValueFromInput(param.name)) + + if algorithm.name: + dependent = model.getDependentAlgorithms(algorithm.name) + else: + dependent = [] + for alg in list(model.algs.values()): + if alg.name not in dependent: + for out in alg.algorithm.outputs: + if (isinstance(out, OutputRaster) + and "%s:%s@" % (alg.name, out.name) in expression): + values.append(ValueFromOutput(alg.name, out.name)) + + algorithm.params[self.LAYERS] = values \ No newline at end of file diff --git a/python/plugins/processing/algs/qgis/ui/ExpressionWidget.ui b/python/plugins/processing/algs/qgis/ui/ExpressionWidget.ui new file mode 100644 index 00000000000..e1b10659a5d --- /dev/null +++ b/python/plugins/processing/algs/qgis/ui/ExpressionWidget.ui @@ -0,0 +1,372 @@ + + + Form + + + + 0 + 0 + 644 + 296 + + + + Form + + + + 0 + + + 0 + + + + + 5 + + + + + Layers (double-click to add) + + + + 0 + + + 0 + + + + + + 300 + 150 + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + Values and operators + + + + 0 + + + -1 + + + 0 + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + 7 + + + + + + + 4 + + + + + + + 2 + + + + + + + ( + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + 1 + + + + + + + 5 + + + + + + + 8 + + + + + + + sqrt + + + + + + + ) + + + + + + + 3 + + + + + + + ^ + + + + + + + - + + + + + + + / + + + + + + + 0 + + + + + + + > + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + * + + + + + + + AND + + + + + + + <= + + + + + + + < + + + + + + + >= + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + OR + + + + + + + = + + + + + + + 6 + + + + + + + . + + + + + + + != + + + + + + + + + + + + + + + 9 + + + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + + + + + Expression + + + + 0 + + + + + + 0 + 0 + + + + + 0 + 100 + + + + + + + + + + + + diff --git a/python/plugins/processing/algs/qgis/ui/RasterCalculatorWidgets.py b/python/plugins/processing/algs/qgis/ui/RasterCalculatorWidgets.py new file mode 100644 index 00000000000..1d4c8fe4903 --- /dev/null +++ b/python/plugins/processing/algs/qgis/ui/RasterCalculatorWidgets.py @@ -0,0 +1,135 @@ +from processing.gui.wrappers import WidgetWrapper, DIALOG_STANDARD, DIALOG_BATCH +from processing.tools import dataobjects +from processing.gui.BatchInputSelectionPanel import BatchInputSelectionPanel +from qgis.PyQt.QtWidgets import QListWidget, QLineEdit, QPushButton +from qgis.PyQt.QtGui import QTextCursor +from processing.core.outputs import OutputRaster +from processing.core.parameters import ParameterRaster +from processing.gui.wrappers import InvalidParameterValue +import os +from qgis.PyQt import uic +from functools import partial + +pluginPath = os.path.dirname(__file__) +WIDGET, BASE = uic.loadUiType( + os.path.join(pluginPath, 'ExpressionWidget.ui')) + + +class ExpressionWidget(BASE, WIDGET): + + def __init__(self, options): + super(ExpressionWidget, self).__init__(None) + self.setupUi(self) + + self.setList(options) + + def doubleClicked(item): + self.text.insertPlainText(self.options[item.text()]) + def addButtonText(text): + if any(c for c in text if c.islower()): + self.text.insertPlainText(" %s()" % text) + self.text.moveCursor(QTextCursor.PreviousCharacter, QTextCursor.MoveAnchor) + else: + self.text.insertPlainText(" %s " % text) + buttons = [b for b in self.buttonsGroupBox.children()if isinstance(b, QPushButton)] + for button in buttons: + button.clicked.connect(partial(addButtonText, button.text())) + self.listWidget.itemDoubleClicked.connect(doubleClicked) + + def setList(self, options): + self.options = options + self.listWidget.clear() + for opt in options.keys(): + self.listWidget.addItem(opt) + + def setValue(self, value): + self.text.setPlainText(value) + + def value(self): + return self.text.toPlainText() + + +class ExpressionWidgetWrapper(WidgetWrapper): + + def _panel(self, options): + return ExpressionWidget(options) + + def createWidget(self): + if self.dialogType == DIALOG_STANDARD: + layers = dataobjects.getRasterLayers(sorting=False) + options = {} + for lyr in layers: + for n in xrange(lyr.bandCount()): + name = '%s@%i' % (lyr.name(), n + 1) + options[name] = name + return self._panel(options) + elif self.dialogType == DIALOG_BATCH: + return QLineEdit() + else: + layers = self.dialog.getAvailableValuesOfType(ParameterRaster, OutputRaster) + options = {self.dialog.resolveValueDescription(lyr): "%s@1" % lyr for lyr in layers} + return self._panel(options) + + def refresh(self): + layers = dataobjects.getRasterLayers() + options = {} + for lyr in layers: + for n in xrange(lyr.bandCount()): + options[lyr.name()] = '%s@%i' % (lyr.name(), n + 1) + self.widget.setList(options) + + def setValue(self, value): + if self.dialogType == DIALOG_STANDARD: + pass # TODO + elif self.dialogType == DIALOG_BATCH: + return self.widget.setText(value) + else: + self.widget.setValue(value) + + def value(self): + if self.dialogType in DIALOG_STANDARD: + return self.widget.value() + elif self.dialogType == DIALOG_BATCH: + return self.widget.text() + else: + return self.widget.value() + + +class LayersListWidgetWrapper(WidgetWrapper): + + def createWidget(self): + if self.dialogType == DIALOG_BATCH: + widget = BatchInputSelectionPanel(self.param, self.row, self.col, self.dialog) + widget.valueChanged.connect(lambda: self.widgetValueHasChanged.emit(self)) + return widget + else: + return None + + def setValue(self, value): + if self.dialogType == DIALOG_BATCH: + return self.widget.setText(value) + + + def value(self): + if self.dialogType == DIALOG_STANDARD: + if self.param.datatype == dataobjects.TYPE_FILE: + return self.param.setValue(self.widget.selectedoptions) + else: + if self.param.datatype == dataobjects.TYPE_RASTER: + options = dataobjects.getRasterLayers(sorting=False) + elif self.param.datatype == dataobjects.TYPE_VECTOR_ANY: + options = dataobjects.getVectorLayers(sorting=False) + else: + options = dataobjects.getVectorLayers([self.param.datatype], sorting=False) + return [options[i] for i in self.widget.selectedoptions] + elif self.dialogType == DIALOG_BATCH: + return self.widget.getText() + else: + options = self._getOptions() + values = [options[i] for i in self.widget.selectedoptions] + if len(values) == 0 and not self.param.optional: + raise InvalidParameterValue() + return values + + + \ No newline at end of file diff --git a/python/plugins/processing/modeler/ModelerAlgorithm.py b/python/plugins/processing/modeler/ModelerAlgorithm.py index 3af5081806f..2e07e8fcb1f 100644 --- a/python/plugins/processing/modeler/ModelerAlgorithm.py +++ b/python/plugins/processing/modeler/ModelerAlgorithm.py @@ -200,7 +200,7 @@ class ValueFromOutput(object): return False def __str__(self): - return self.alg + "," + self.output + return self.alg + ":" + self.output def asPythonParameter(self): return "outputs_%s['%s']" % (self.alg, self.output)