From d40eb93fedf7d6eaa3de9401e2504a1099eedc6d Mon Sep 17 00:00:00 2001 From: erpas Date: Mon, 7 Dec 2020 22:03:37 +0100 Subject: [PATCH] Switch to multiple cell tools/operations. Added raster cell selection tools with saving/loading selections to/from map layers. Added QGIS expressions support including probing rasters and interpolation on vector and mesh layers. Added low-pass 3x3 raster filtering. Multiband rasters are now fully supported. --- Serval/band_spin_boxes.py | 87 ++ Serval/icons/all_touched.svg | 181 +++ Serval/icons/apply_const_value.svg | 227 ++++ Serval/icons/apply_expression_value.svg | 149 +++ Serval/icons/apply_low_pass_filter.svg | 144 +++ Serval/icons/apply_nodata_value.svg | 216 ++++ Serval/icons/clear_selection.svg | 163 +++ Serval/icons/const_fill.svg | 115 ++ Serval/icons/edit_settings.svg | 1028 ++++++++++++++++ Serval/icons/help.svg | 54 +- Serval/icons/line_width.svg | 63 + Serval/icons/rgb_band_mode.svg | 177 +++ Serval/icons/select_from_layer.svg | 179 +++ Serval/icons/select_line.svg | 128 ++ Serval/icons/select_polygon.svg | 138 +++ Serval/icons/select_tool.svg | 55 + Serval/icons/selection_to_layer.svg | 273 +++++ .../{define_nodata.svg => set_nodata.svg} | 47 +- Serval/icons/success.svg | 1 + Serval/layer_select_dlg.py | 23 + Serval/metadata.txt | 23 +- Serval/raster_changes.py | 56 + Serval/raster_handler.py | 305 +++++ Serval/selection_tool.py | 224 ++++ Serval/serval.py | 1070 ++++++++++------- Serval/serval_exp_functions.py | 137 +++ Serval/user_communication.py | 3 + Serval/utils.py | 73 +- tests/src_data/10x5_float64_EPSG27700_org.tif | Bin 0 -> 855 bytes tests/src_data/10x5_int16_EPSG27700_org.tif | Bin 0 -> 495 bytes 30 files changed, 4820 insertions(+), 519 deletions(-) create mode 100644 Serval/band_spin_boxes.py create mode 100644 Serval/icons/all_touched.svg create mode 100644 Serval/icons/apply_const_value.svg create mode 100644 Serval/icons/apply_expression_value.svg create mode 100644 Serval/icons/apply_low_pass_filter.svg create mode 100644 Serval/icons/apply_nodata_value.svg create mode 100644 Serval/icons/clear_selection.svg create mode 100644 Serval/icons/const_fill.svg create mode 100644 Serval/icons/edit_settings.svg create mode 100644 Serval/icons/line_width.svg create mode 100644 Serval/icons/rgb_band_mode.svg create mode 100644 Serval/icons/select_from_layer.svg create mode 100644 Serval/icons/select_line.svg create mode 100644 Serval/icons/select_polygon.svg create mode 100644 Serval/icons/select_tool.svg create mode 100644 Serval/icons/selection_to_layer.svg rename Serval/icons/{define_nodata.svg => set_nodata.svg} (95%) create mode 100644 Serval/icons/success.svg create mode 100644 Serval/layer_select_dlg.py create mode 100644 Serval/raster_changes.py create mode 100644 Serval/raster_handler.py create mode 100644 Serval/selection_tool.py create mode 100644 Serval/serval_exp_functions.py create mode 100644 tests/src_data/10x5_float64_EPSG27700_org.tif create mode 100644 tests/src_data/10x5_int16_EPSG27700_org.tif diff --git a/Serval/band_spin_boxes.py b/Serval/band_spin_boxes.py new file mode 100644 index 0000000..61252a9 --- /dev/null +++ b/Serval/band_spin_boxes.py @@ -0,0 +1,87 @@ +from qgis.PyQt.QtCore import QSize, Qt, pyqtSignal +from qgis.PyQt.QtWidgets import ( + QAbstractSpinBox, + QHBoxLayout, + QWidget, +) +from qgis.gui import QgsDoubleSpinBox + +from .utils import dtypes + + +class BandBox(QgsDoubleSpinBox): + + enter_hit = pyqtSignal() + + def __init__(self, parent=None): + super(BandBox, self).__init__(parent=parent) + self.setMinimumSize(QSize(50, 24)) + self.setMaximumSize(QSize(50, 24)) + self.setAlignment(Qt.AlignLeft) + self.setButtonSymbols(QAbstractSpinBox.NoButtons) + self.setKeyboardTracking(False) + self.setShowClearButton(False) + self.setExpressionsEnabled(False) + self.setStyleSheet("") + + def keyPressEvent(self, event): + super(BandBox, self).keyPressEvent(event) + if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter: + self.enter_hit.emit() + + +class BandBoxes(QWidget): + + enter_hit = pyqtSignal(list) + + def __init__(self, bands=None, data_types=None, nodata_values=None, parent=None): + super(BandBoxes, self).__init__(parent=parent) + self.bands = bands if bands else [1] + self.data_types = data_types if data_types else [6] + self.nodata_values = nodata_values + self.sbox = None + lout = QHBoxLayout() + lout.setSpacing(1) + self.setLayout(lout) + self.create_spinboxes(self.bands, self.data_types, self.nodata_values) + + def remove_spinboxes(self): + for i in reversed(range(self.layout().count())): + self.layout().itemAt(i).widget().deleteLater() + self.sbox = None + + def create_spinboxes(self, bands, data_types, nodata_values): + self.remove_spinboxes() + self.sbox = dict() + self.bands = bands + self.data_types = data_types + self.nodata_values = nodata_values + for nr in self.bands: + dt = self.data_types[nr - 1] + self.sbox[nr] = BandBox() + self.sbox[nr].setMinimum(dtypes[dt]['min']) + self.sbox[nr].setMaximum(dtypes[dt]['max']) + self.sbox[nr].setDecimals(dtypes[dt]['dig']) + self.sbox[nr].setExpressionsEnabled(True) + self.layout().addWidget(self.sbox[nr]) + self.sbox[nr].enter_hit.connect(self.enter_key_pressed) + + def enable(self, enable=True): + for nr in self.sbox: + self.sbox[nr].setEnabled(enable) + + def set_values(self, values): + for nr in self.sbox: + new_val = self.nodata_values[nr - 1] if values[nr - 1] is None else values[nr - 1] + self.sbox[nr].setValue(new_val) + + def get_values(self): + values = [] + for nr in self.sbox: + raw_val = self.sbox[nr].text().replace(",", ".") + value = int(raw_val) if self.data_types[nr -1] < 6 else float(raw_val) + values.append(value) + return values + + def enter_key_pressed(self): + self.enter_hit.emit(self.get_values()) diff --git a/Serval/icons/all_touched.svg b/Serval/icons/all_touched.svg new file mode 100644 index 0000000..345acc4 --- /dev/null +++ b/Serval/icons/all_touched.svg @@ -0,0 +1,181 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Serval/icons/apply_const_value.svg b/Serval/icons/apply_const_value.svg new file mode 100644 index 0000000..ceee1bc --- /dev/null +++ b/Serval/icons/apply_const_value.svg @@ -0,0 +1,227 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Serval/icons/apply_expression_value.svg b/Serval/icons/apply_expression_value.svg new file mode 100644 index 0000000..999fa50 --- /dev/null +++ b/Serval/icons/apply_expression_value.svg @@ -0,0 +1,149 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Serval/icons/apply_low_pass_filter.svg b/Serval/icons/apply_low_pass_filter.svg new file mode 100644 index 0000000..861c836 --- /dev/null +++ b/Serval/icons/apply_low_pass_filter.svg @@ -0,0 +1,144 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Serval/icons/apply_nodata_value.svg b/Serval/icons/apply_nodata_value.svg new file mode 100644 index 0000000..5593629 --- /dev/null +++ b/Serval/icons/apply_nodata_value.svg @@ -0,0 +1,216 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Serval/icons/clear_selection.svg b/Serval/icons/clear_selection.svg new file mode 100644 index 0000000..7d44e95 --- /dev/null +++ b/Serval/icons/clear_selection.svg @@ -0,0 +1,163 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Serval/icons/const_fill.svg b/Serval/icons/const_fill.svg new file mode 100644 index 0000000..9d09d2b --- /dev/null +++ b/Serval/icons/const_fill.svg @@ -0,0 +1,115 @@ + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + diff --git a/Serval/icons/edit_settings.svg b/Serval/icons/edit_settings.svg new file mode 100644 index 0000000..9f0834b --- /dev/null +++ b/Serval/icons/edit_settings.svg @@ -0,0 +1,1028 @@ + + + edit + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + image/svg+xml + + edit + 2012-08-16 + + + Robert Szczepanek + + + + + Robert Szczepanek + + + + + edit + + + GIS icons 0.2 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Serval/icons/help.svg b/Serval/icons/help.svg index 37d520a..4b58e12 100644 --- a/Serval/icons/help.svg +++ b/Serval/icons/help.svg @@ -1,6 +1,4 @@ - - image/svg+xml @@ -1127,14 +1129,18 @@ height="21.919764" x="1.5961742" y="1.0759891" /> \ No newline at end of file + sodipodi:nodetypes="cccccccccccccccccccccccccccccccccccccccccccccccccc" />? diff --git a/Serval/icons/line_width.svg b/Serval/icons/line_width.svg new file mode 100644 index 0000000..78cea5d --- /dev/null +++ b/Serval/icons/line_width.svg @@ -0,0 +1,63 @@ + + + + + + image/svg+xml + + + + + + + + + + + diff --git a/Serval/icons/rgb_band_mode.svg b/Serval/icons/rgb_band_mode.svg new file mode 100644 index 0000000..982736a --- /dev/null +++ b/Serval/icons/rgb_band_mode.svg @@ -0,0 +1,177 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Serval/icons/select_from_layer.svg b/Serval/icons/select_from_layer.svg new file mode 100644 index 0000000..d108304 --- /dev/null +++ b/Serval/icons/select_from_layer.svg @@ -0,0 +1,179 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Serval/icons/select_line.svg b/Serval/icons/select_line.svg new file mode 100644 index 0000000..6c618cb --- /dev/null +++ b/Serval/icons/select_line.svg @@ -0,0 +1,128 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + diff --git a/Serval/icons/select_polygon.svg b/Serval/icons/select_polygon.svg new file mode 100644 index 0000000..e4f0c59 --- /dev/null +++ b/Serval/icons/select_polygon.svg @@ -0,0 +1,138 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Serval/icons/select_tool.svg b/Serval/icons/select_tool.svg new file mode 100644 index 0000000..92fa3d9 --- /dev/null +++ b/Serval/icons/select_tool.svg @@ -0,0 +1,55 @@ + + + + + + image/svg+xml + + + + + + + + + diff --git a/Serval/icons/selection_to_layer.svg b/Serval/icons/selection_to_layer.svg new file mode 100644 index 0000000..e0f2b7a --- /dev/null +++ b/Serval/icons/selection_to_layer.svg @@ -0,0 +1,273 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Serval/icons/define_nodata.svg b/Serval/icons/set_nodata.svg similarity index 95% rename from Serval/icons/define_nodata.svg rename to Serval/icons/set_nodata.svg index cd03a97..88e305b 100644 --- a/Serval/icons/define_nodata.svg +++ b/Serval/icons/set_nodata.svg @@ -1,6 +1,4 @@ - - + inkscape:snap-global="false" + inkscape:document-rotation="0"> + originx="2.5" + originy="2.5" + spacingx="1" + spacingy="1" /> @@ -987,15 +986,15 @@ + style="display:inline"> + transform="matrix(0.77050839,-0.07655622,0.07655622,0.77050839,-84.052108,-779.73362)"> + style="fill:#ffffff;fill-opacity:0.577586;stroke:#3b3b3b;stroke-width:1.28936;stroke-miterlimit:4;stroke-dasharray:none;stroke-opacity:1" /> diff --git a/Serval/icons/success.svg b/Serval/icons/success.svg new file mode 100644 index 0000000..db7b583 --- /dev/null +++ b/Serval/icons/success.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/Serval/layer_select_dlg.py b/Serval/layer_select_dlg.py new file mode 100644 index 0000000..6c854e3 --- /dev/null +++ b/Serval/layer_select_dlg.py @@ -0,0 +1,23 @@ +from qgis.PyQt.QtWidgets import ( + QDialog, + QDialogButtonBox, + QVBoxLayout, +) +from qgis.core import QgsMapLayerProxyModel +from qgis.gui import QgsMapLayerComboBox + + +class LayerSelectDialog(QDialog): + def __init__(self, parent=None, title=""): + super(QDialog, self).__init__(parent) + + self.cbo = QgsMapLayerComboBox() + self.cbo.setFilters(QgsMapLayerProxyModel.VectorLayer) + self.btns = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.btns.accepted.connect(self.accept) + self.btns.rejected.connect(self.reject) + lout = QVBoxLayout() + lout.addWidget(self.cbo) + lout.addWidget(self.btns) + self.setLayout(lout) + self.setWindowTitle("Choose layer for selection") diff --git a/Serval/metadata.txt b/Serval/metadata.txt index 54640f4..690f7aa 100644 --- a/Serval/metadata.txt +++ b/Serval/metadata.txt @@ -1,23 +1,26 @@ [general] name=Serval -qgisMinimumVersion=2.99 +qgisMinimumVersion=3.10 qgisMaximumVersion=3.99 -description=Map tool for manipulating raster cell values. -version=3.0.1 +description=Raster editing tools +version=3.10.0 author=Radoslaw Pasiok for Lutra Consulting Ltd. email=radek.pasiok@lutraconsulting.co.uk - -about=Usage: The tool has 3 modes: probing, drawing and erasing. In the probing mode cell values are read and can be changed manually in a spinbox. In drawing mode, chosen band values are set to a raster cell and immediately written to disk. Erasing is setting a raster cell value to NoData. For 3 bands rasters, native QGIS color picker can be used to define bands values. -Warning: All changes are immediately written to disk. Always keep backup of your original raster! - +about=Raster editing tools - select and apply a modification to a chosen part of a raster. Features setting a constant value (also NoData), an expression (probing other rasters and interpolation on vector and mesh layers) and applying 3x3 low-pass filter. Multiband rasters supported. tracker=http://github.com/erpas/serval/issues repository=http://github.com/erpas/serval -# End of mandatory metadata +# End of mandatory metadata # Recommended items: - # Uncomment the following line and add your changelog: + changelog= + 3.10.0 New features: + * multi-band rasters support + * raster cells selection tools + * saving/loading selections to/from map layers + * QGIS expressions support including interpolation from vector and mesh layers + * low-pass 3x3 raster filtering 3.0.1 Fixed setting working raster in Linux (issue #18) 3.0 Port to QGIS 3 0.8.1 Added support for Float64 data type @@ -32,7 +35,7 @@ changelog= # Tags are comma separated with spaces allowed -tags=raster, grid, pixel, value, cell, draw, nodata +tags=raster, band, multiband, grid, pixel, value, cell, draw, nodata, select, selection, filter, expression homepage=https://github.com/erpas/serval/wiki category=Raster diff --git a/Serval/raster_changes.py b/Serval/raster_changes.py new file mode 100644 index 0000000..5966d70 --- /dev/null +++ b/Serval/raster_changes.py @@ -0,0 +1,56 @@ +from qgis.PyQt.QtCore import QObject + + +class RasterChange(object): + """Class for storing a change made to raster, i.e. raster blocks before and after the change.""" + + def __init__(self, active_bands, row, col, old_blocks, new_blocks): + self.active_bands = active_bands # list of bands for the change + self.row = row # top left row and col of the blocks with changes + self.col = col + self.old_blocks = old_blocks # list of blocks for each raster band + self.new_blocks = new_blocks + + def get_undo(self): + return self.active_bands, self.row, self.col, self.old_blocks + + def get_redo(self): + return self.active_bands, self.row, self.col, self.new_blocks + + +class RasterChanges(QObject): + """Class for managing changes made to a raster.""" + + def __init__(self, nr_to_keep=3): + super(RasterChanges, self).__init__() + self.undos = [] # list of RasterChange objects + self.redos = [] + self.nr_to_keep = nr_to_keep + + def clear(self): + self.undos = [] + self.redos = [] + + def add_change(self, change): + keep = max(0, self.nr_to_keep - 1) + self.undos = self.undos[-keep:] + self.undos.append(change) + self.redos = [] + + def undo(self): + last_change = self.undos.pop() + keep = max(0, self.nr_to_keep - 1) + self.redos = self.redos[-keep:] + self.redos.append(last_change) + return last_change.get_undo() + + def redo(self): + last_change = self.redos.pop() + self.undos.append(last_change) + return last_change.get_redo() + + def nr_undos(self): + return len(self.undos) + + def nr_redos(self): + return len(self.redos) diff --git a/Serval/raster_handler.py b/Serval/raster_handler.py new file mode 100644 index 0000000..4795c7e --- /dev/null +++ b/Serval/raster_handler.py @@ -0,0 +1,305 @@ +import math + +from qgis.core import ( + QgsCoordinateTransform, + QgsCsException, + QgsFeature, + QgsGeometry, + QgsPointXY, + QgsProject, + QgsRasterBlock, + QgsRectangle, + QgsSpatialIndex, + QgsVectorLayer, +) +from qgis.PyQt.QtCore import pyqtSignal, QObject +from .utils import get_logger, dtypes, low_pass_filtered +from .raster_changes import RasterChange + + +class RasterHandler(QObject): + """Raster layer handler.""" + + raster_changed = pyqtSignal(object) + + def __init__(self, layer, canvas_crs, uc=None, debug=False): + super(RasterHandler, self).__init__() + self.layer = layer + self.canvas_crs = canvas_crs + self.uc = uc + self.logger = get_logger() if debug else None + self.provider = layer.dataProvider() + self.bands_nr = self.layer.bandCount() + self.bands_range = range(1, self.bands_nr + 1) + self.active_bands = [1] + self.require_transform = self.canvas_crs != self.layer.crs() + self.data_types = None + self.nodata_values = None + self.pixel_size_x = self.layer.rasterUnitsPerPixelX() + self.pixel_size_y = self.layer.rasterUnitsPerPixelY() + self.raster_cols = self.layer.width() + self.raster_rows = self.layer.height() + self.layer_extent = self.provider.extent() + self.min_x = self.layer_extent.xMinimum() + self.min_y = self.layer_extent.yMinimum() + self.max_x = self.layer_extent.xMaximum() + self.max_y = self.layer_extent.yMaximum() + self.origin_x = self.min_x + self.origin_y = self.max_y + self.first_pixel_x = self.min_x + self.pixel_size_x / 2. # x coord of upper left pixel center + self.first_pixel_y = self.max_y - self.pixel_size_y / 2. # y + self.cell_centers = None # dict of coordinates of currently selected cells centers {(row, col): (x, y)} + self.cell_exp_val = None # dict of evaluated expressions for cells centers {(row, col): value} + self.cell_pts_layer = None # point memory layer with selected cells centers + self.selecting_geoms = None # dictionary of selecting geometries {id: geometry} + self.spatial_index = None # spatial index of selecting geometries extents + self.block_row_min = None # range of indices of the raster block to modify + self.block_row_max = None + self.block_col_min = None + self.block_col_max = None + self.selected_cells = None # list of selected cells as tuples of global indices (row, cell) + self.selected_cells_feats = None # {(row, cell): feature} + self.total_geometry = None + self.all_touched_cells = None + self.exp_field_idx = None + self.get_data_types() + self.get_nodata_values() + + def get_data_types(self): + self.data_types = [] + for nr in self.bands_range: + self.data_types.append(self.provider.dataType(nr)) + + def write_supported(self): + msg = "" + supported = True + for nr in self.bands_range: + if self.provider.dataType(nr) == 0 or self.provider.dataType(nr) > 7: + msg = f"{dtypes[self.provider.dataType(nr)]['name']} (band {nr})" + supported = False + return supported, msg + + def get_nodata_values(self): + self.nodata_values = [] + for nr in self.bands_range: + if self.provider.sourceHasNoDataValue(nr): + self.nodata_values.append(self.provider.sourceNoDataValue(nr)) + self.provider.setUseSourceNoDataValue(nr, True) + # no nodata defined in the raster source + else: + # check if user defined any nodata values + if self.provider.userNoDataValues(nr): + # get min nodata value from the first user nodata range + nd_ranges = self.provider.userNoDataValues(nr) + self.nodata_values.append(nd_ranges[0].min()) + else: + # leave nodata undefined + self.nodata_values.append(None) + + def select(self, geometries, all_touched_cells=True): + """ + For the geometries list, find selected cells. + If all_touched_cells is True, all cells touching a geometry will be selected. + Otherwise, a geometry must intersect a cell center to select it. + """ + if self.logger: + self.logger.debug(f"Selecting cells for geometries: {[g.asWkt() for g in geometries]}") + self.selecting_geoms = dict() + self.selected_cells = [] + self.spatial_index = QgsSpatialIndex() + self.total_geometry = QgsGeometry() + if self.require_transform: + project = QgsProject.instance() + srs_transform = QgsCoordinateTransform(self.canvas_crs, self.layer.crs(), project) + dxy = 0.001 + geoms = [] + for nr, geom in enumerate(geometries): + if not geom.isGeosValid(): + continue + sgeom = QgsGeometry(geom) + if self.require_transform: + try: + res = sgeom.transform(srs_transform) + if not res == QgsGeometry.Success: + raise QgsCsException(repr(res)) + except QgsCsException as err: + msg = "Raster transformation failed! Check the raster projection settings." + if self.uc: + self.uc.bar_warn(msg, dur=5) + msg += repr(err) + if self.logger: + self.logger.warning(msg) + return + + self.selecting_geoms[nr] = sgeom + self.spatial_index.addFeature(nr, sgeom.boundingBox()) + geoms.append(sgeom) + self.total_geometry = QgsGeometry.unaryUnion(geoms) + self.block_row_min, self.block_row_max, self.block_col_min, self.block_col_max = \ + self.extent_to_cell_indices(self.total_geometry.boundingBox()) + + half_pix_x = self.pixel_size_x / 2. + half_pix_y = self.pixel_size_y / 2. + self.cell_centers = dict() + for row in range(self.block_row_min, self.block_row_max + 1): + for col in range(self.block_col_min, self.block_col_max + 1): + pt_x = self.first_pixel_x + col * self.pixel_size_x + pt_y = self.first_pixel_y - row * self.pixel_size_y + if all_touched_cells: + bbox = QgsRectangle(pt_x - half_pix_x, pt_y - half_pix_y, + pt_x + half_pix_x, pt_y + half_pix_y) + else: + bbox = QgsRectangle(pt_x, pt_y, pt_x + dxy, pt_y + dxy) + sel_inter = self.spatial_index.intersects(bbox) + for sel_geom_id in sel_inter: + g = self.selecting_geoms[sel_geom_id] + if g.intersects(bbox): + self.selected_cells.append((row, col)) + self.cell_centers[(row, col)] = (pt_x, pt_y) + if self.logger: + self.logger.debug(f"Nr of cells selected: {len(self.selected_cells)}") + + def create_cell_pts_layer(self): + """For current block extent, create memory point layer with a feature in each selected cell.""" + lyr_crs = "crs=EPSG:27700" + fields_def = "field=x:double&field=y:double&field=row:int&field=col:int" + self.cell_pts_layer = QgsVectorLayer(f"Point?{lyr_crs}&{fields_def}", "x", "memory") + fields = self.cell_pts_layer.dataProvider().fields() + feats = [] + for row_col, xy in self.cell_centers.items(): + row, col = row_col + x, y = xy + feat = QgsFeature(fields) + feat.setGeometry(QgsGeometry.fromPointXY(QgsPointXY(x, y))) + feat["row"] = row + feat["col"] = col + feats.append(feat) + self.cell_pts_layer.dataProvider().addFeatures(feats) + self.selected_cells_feats = dict() # {(row, cell): feat} + for feat in self.cell_pts_layer.getFeatures(): + self.selected_cells_feats[(feat["row"], feat["col"])] = feat.id() + + def write_block(self, const_values=None, low_pass_filter=False): + """ + Construct raster block for each band, apply the values and write to file. + If const_values are given (a list of const values for each band) they are used for each selected cell. + In other case the memory layer with values calculated for each cell selected will be used. + Alternatively, selected cells values can be filtered using low-pass 3x3 filter. + """ + if self.logger: + vals = f"const values ({const_values})" if const_values else "expression values." + self.logger.debug(f"Writing blocks with {vals}") + if not self.provider.isEditable(): + res = self.provider.setEditable(True) + if not res: + if self.uc: + self.uc.show_warn('QGIS can\'t modify this type of raster') + return None + if self.logger: + self.logger.debug("Calculating block origin coordinates...") + b_orig_x, b_orig_y = self.index_to_point(self.block_row_min, self.block_col_min) + cols = self.block_col_max - self.block_col_min + 1 + rows = self.block_row_max - self.block_row_min + 1 + b_end_x = b_orig_x + cols * self.pixel_size_x + b_end_y = b_orig_y - rows * self.pixel_size_y + block_bbox = QgsRectangle(b_orig_x, b_end_y, b_end_x, b_orig_y) + if self.logger: + self.logger.debug(f"Block bbox: {block_bbox.toString()}") + self.logger.debug(f"Nr of cells in the block: rows={rows}, cols={cols}") + old_blocks = [] + new_blocks = [] + cell_values = dict() + if const_values is None and not low_pass_filter: + for feat in self.cell_pts_layer.getFeatures(): + cell_values[feat.id()] = feat.attribute(self.exp_field_idx) + for band_nr in self.active_bands: + block = self.provider.block(band_nr, block_bbox, cols, rows) + new_blocks.append(block) + block_data = block.data().data() + old_block = QgsRasterBlock(self.data_types[band_nr - 1], cols, rows) + old_block.setData(block_data) + for abs_row, abs_col in self.selected_cells: + row = abs_row - self.block_row_min + col = abs_col - self.block_col_min + if const_values: + idx = band_nr - 1 if len(self.active_bands) > 1 else 0 + new_val = const_values[idx] + elif low_pass_filter: + # the filter is applied for cells inside the block only + if block.height() < 3 or block.width() < 3: + # the selected block is too small for filtering -> keep the old value + new_val = None + else: + new_val = low_pass_filtered(old_block, row, col, self.nodata_values[band_nr - 1]) + else: + # set the expression value + feat_id = self.selected_cells_feats[(abs_row, abs_col)] + new_val = None if math.isnan(cell_values[feat_id]) else cell_values[feat_id] + new_val = old_block.value(row, col) if new_val is None else new_val + set_res = block.setValue(row, col, new_val) + if self.logger: + self.logger.debug(f"Setting block value for band {band_nr}, row {row}, col: {col}: {set_res}") + old_blocks.append(old_block) + band_res = self.provider.writeBlock(block, band_nr, self.block_col_min, self.block_row_min) + if self.logger: + self.logger.debug(f"Writing block for band {band_nr}: {band_res}") + self.provider.setEditable(False) + change = RasterChange(self.active_bands, self.block_row_min, self.block_col_min, old_blocks, new_blocks) + self.raster_changed.emit(change) + return True + + def write_block_undo(self, data): + """Write blocks from the undo / redo stack.""" + if self.logger: + self.logger.debug(f"Writing blocks from undo") + if not self.provider.isEditable(): + res = self.provider.setEditable(True) + bands, row_min, col_min, blocks = data + for band_nr in bands: + idx = band_nr - 1 if len(bands) > 1 else 0 + block = blocks[idx] + band_res = self.provider.writeBlock(block, band_nr, col_min, row_min) + if self.logger: + self.logger.debug(f"Writing undo/redo block for band {band_nr}: {band_res}") + self.provider.setEditable(False) + + def extent_to_cell_indices(self, extent): + """Return x and y raster cell indices ranges for the extent.""" + col_min, row_max = self.point_to_index((extent.xMinimum(), extent.yMinimum())) + col_max, row_min = self.point_to_index((extent.xMaximum(), extent.yMaximum())) + if self.logger: + self.logger.debug(f"Cell ranges for extent {extent.toString(precision=3)} = row_min: {row_min}, " + + f"row_max: {row_max}, col_min: {col_min}, col_max: {col_max}") + return row_min, row_max, col_min, col_max + + def index_to_point(self, row, col, upper_left=True): + """Return cell upper left corner or cell center coordinates.""" + x0 = self.origin_x if upper_left else self.first_pixel_x + y0 = self.origin_y if upper_left else self.first_pixel_y + x, y = x0 + col * self.pixel_size_x, y0 - row * self.pixel_size_y + if self.logger: + self.logger.debug(f"Coords for ({row}, {col}) = ({x}, {y}) (x, y)") + return x, y + + def point_to_index(self, coords): + """ + Return raster cell indices for the coordinates. + If it falls outside of the layer extent, then the first or last index is returned. + """ + if self.origin_x <= coords[0] <= self.max_x: + x_offset = coords[0] - self.origin_x + col = math.floor(x_offset / self.pixel_size_x) + elif coords[0] < self.origin_x: + col = 0 + else: + col = self.raster_cols - 1 + + if self.min_y <= coords[1] <= self.origin_y: + y_offset = self.origin_y - coords[1] + row = math.floor(y_offset / self.pixel_size_y) + elif coords[1] > self.origin_y: + row = 0 + else: + row = self.raster_rows - 1 + + return col, row diff --git a/Serval/selection_tool.py b/Serval/selection_tool.py new file mode 100644 index 0000000..75280a1 --- /dev/null +++ b/Serval/selection_tool.py @@ -0,0 +1,224 @@ +from qgis.PyQt.QtCore import Qt +from qgis.PyQt.QtGui import QPixmap, QCursor, QColor +from qgis.PyQt.QtWidgets import QApplication +from qgis.core import QgsWkbTypes, QgsGeometry +from qgis.gui import QgsMapTool, QgsRubberBand + +from .utils import icon_path, get_logger + + +class RasterCellSelectionMapTool(QgsMapTool): + """ + Raster cells selection tool + """ + + NEW_SELECTION = "New selection" + ADD_TO_SELECTION = "Add to selection" + REMOVE_FROM_SELECTION = "Remove from selection" + LINE_SELECTION = "line" + POLYGON_SELECTION = "polygon" + + def __init__(self, iface, uc, raster, debug=False): + super(RasterCellSelectionMapTool, self).__init__(iface.mapCanvas()) + self.iface = iface + self.uc = uc + self.raster = raster + self.mode = None + self.geom_type = QgsWkbTypes.PolygonGeometry + self.setCursor(QCursor(QPixmap(icon_path('select_tool.svg')), hotX=0, hotY=0)) + self.current_rubber_band = QgsRubberBand(self.iface.mapCanvas(), QgsWkbTypes.PolygonGeometry) + self.selected_rubber_band = QgsRubberBand(self.iface.mapCanvas(), QgsWkbTypes.PolygonGeometry) + self.current_points = None + self.selected_geometries = None + self.last_pos = None + self.sel_line_width = 1 + self.cur_sel_color = QColor(Qt.yellow) + self.cur_sel_fill_color = QColor(Qt.yellow) + self.sel_color = QColor(Qt.yellow) + self.sel_fill_color = QColor(Qt.yellow) + self.prev_tool = None + self.selection_mode = None + self.logger = get_logger() if debug else None + + def init_tool(self, raster, mode=POLYGON_SELECTION, line_width=1): + if not raster: + self.uc.bar_warn("No raster selected") + return False + self.raster = raster + self.mode = mode + self.sel_line_width = line_width + self.geom_type = QgsWkbTypes.LineGeometry if mode == self.LINE_SELECTION else QgsWkbTypes.PolygonGeometry + self.cur_sel_color = Qt.yellow + self.cur_sel_fill_color = QColor(Qt.yellow) + self.cur_sel_fill_color.setAlpha(20) + self.sel_color = Qt.yellow + self.sel_fill_color = QColor(Qt.yellow) + self.sel_fill_color.setAlpha(20) + return True + + def set_prev_tool(self, prev_tool): + self.prev_tool = prev_tool + + def activate(self): + self.setCursor(QCursor(QPixmap(icon_path('select_tool.svg')), hotX=0, hotY=0)) + if self.logger: + self.logger.debug(f"Selection tool activated") + + def deactivate(self): + QgsMapTool.deactivate(self) + if self.logger: + self.logger.debug(f"Selection tool deactivated") + + def reset(self): + if self.logger: + self.logger.debug(f"Resetting the tool completely") + self.current_rubber_reset() + self.selected_rubber_reset() + self.raster = None + self.current_points = None + self.selected_geometries = None + + def selecting_finished(self): + if self.logger: + self.logger.debug(f"Selecting finished") + self.current_selection_reset() + + def current_rubber_reset(self, col=None, fill_col=None, width=1, geom_type=QgsWkbTypes.PolygonGeometry): + if self.current_rubber_band is None: + return + self.current_rubber_band.reset(geom_type) + self.current_rubber_band.setColor(col if col else self.cur_sel_color) + self.current_rubber_band.setWidth(width) + self.current_rubber_band.setFillColor(fill_col if fill_col else self.cur_sel_fill_color) + + def current_selection_reset(self): + self.current_points = [] + self.current_rubber_reset() + + def selected_rubber_reset(self, col=None, fill_col=None, width=1, geom_type=QgsWkbTypes.PolygonGeometry): + if self.selected_rubber_band is None: + return + self.selected_rubber_band.reset(geom_type) + self.selected_rubber_band.setColor(col if col else self.sel_color) + self.selected_rubber_band.setWidth(width) + self.selected_rubber_band.setFillColor(fill_col if fill_col else self.sel_fill_color) + + def clear_all_selections(self): + self.current_selection_reset() + self.selected_rubber_reset() + self.selected_geometries = None + + def create_selecting_geometry(self, cur_position=None): + pt = [cur_position] if cur_position else [] + if self.geom_type == QgsWkbTypes.LineGeometry: + geom = QgsGeometry.fromPolylineXY(self.current_points + pt).buffer(self.sel_line_width / 2., 5) + else: + if len(self.current_points) < 2: + geom = QgsGeometry.fromPolylineXY(self.current_points + pt) + else: + poly_pts = [self.current_points + pt] + geom = QgsGeometry.fromPolygonXY(poly_pts) + return geom + + def current_rubber_update(self, cur_position=None): + self.current_rubber_reset() + if not self.current_points: + return + geom = self.create_selecting_geometry(cur_position=cur_position) + self.current_rubber_band.addGeometry(geom, None) + if geom.isGeosValid(): + self.uc.clear_bar_messages() + else: + self.uc.bar_warn("Selected geometry is invalid") + + def selected_rubber_update(self): + self.selected_rubber_reset() + if self.selected_geometries is None: + return + for geom in self.selected_geometries: + self.selected_rubber_band.addGeometry(geom, None) + + def canvasMoveEvent(self, e): + if self.current_points is None: + return + self.current_rubber_update(self.toMapCoordinates(e.pos())) + self.last_pos = self.toMapCoordinates(e.pos()) + + def keyPressEvent(self, e): + if e.key() == Qt.Key_Escape: + self.uc.bar_info("Tool aborted") + self.selecting_finished() + elif e.key() == Qt.Key_Backspace: + if self.current_points: + self.current_points.pop() + self.current_rubber_update(cur_position=self.last_pos if self.last_pos else None) + + def canvasReleaseEvent(self, e): + if e.button() == Qt.RightButton: + modifiers = QApplication.keyboardModifiers() + if modifiers == Qt.ShiftModifier: + self.selection_mode = self.REMOVE_FROM_SELECTION + elif modifiers == Qt.ControlModifier: + self.selection_mode = self.ADD_TO_SELECTION + else: + self.selection_mode = self.NEW_SELECTION + if self.logger: + self.logger.debug(f"Right mouse button in mode: {self.selection_mode}") + self.update_selection() + return + if e.button() != Qt.LeftButton: + return + + cur_pos = self.toMapCoordinates(e.pos()) + if self.current_points is None: + self.current_points = [cur_pos] + else: + self.current_points.append(cur_pos) + self.current_rubber_update(cur_position=cur_pos) + + def update_selection(self): + if self.logger: + self.logger.debug(f"Selection tool points: {[str(pt) for pt in self.current_points]}") + if self.current_points is None: + return + + new_geom = self.create_selecting_geometry() + if new_geom.isEmpty(): + if self.logger: + self.logger.debug(f"Selection geometry was empty.") + return + if self.selection_mode == self.NEW_SELECTION: + self.selected_geometries = [new_geom] + elif self.selection_mode == self.ADD_TO_SELECTION: + self.selected_geometries.append(new_geom) + else: + # distract from existing geometries + new_geoms = [] + for exist_geom in self.selected_geometries: + geom = exist_geom.difference(new_geom) + if not geom.isGeosValid() or not geom.type() == QgsWkbTypes.PolygonGeometry: + if self.logger: + self.logger.debug(f"Invalid geometry for selection: {geom.asWkt()}") + continue + new_geoms.append(geom) + self.selected_geometries = new_geoms + self.selected_rubber_update() + self.current_selection_reset() + self.uc.bar_info("Selection created") + + def selection_from_layer(self, layer): + if self.logger: + self.logger.debug(f"Selection from layer: {layer.name()}") + sel_geoms = [] + features = layer.getSelectedFeatures() if layer.selectedFeatureCount() else layer.getFeatures() + for feat in features: + if layer.geometryType() == QgsWkbTypes.LineGeometry or layer.geometryType() == QgsWkbTypes.PointGeometry: + geom = feat.geometry().buffer(self.sel_line_width / 2., 5) + else: + geom = feat.geometry() + if geom.isGeosValid(): + sel_geoms.append(geom) + self.selected_geometries = sel_geoms + self.selected_rubber_update() + self.current_selection_reset() + self.uc.bar_info("Selection loaded") diff --git a/Serval/serval.py b/Serval/serval.py index c1e781e..20daa2d 100644 --- a/Serval/serval.py +++ b/Serval/serval.py @@ -1,13 +1,9 @@ -# -*- coding: utf-8 -*- """ /*************************************************************************** - serval, A QGIS plugin - - - Map tools for manipulating raster cell values + Serval, a QGIS plugin for manipulating raster cell values begin : 2015-12-30 - copyright : (C) 2019 Radosław Pasiok for Lutra Consulting Ltd. + copyright : (C) 2020 Radosław Pasiok for Lutra Consulting Ltd. email : info@lutraconsulting.co.uk ***************************************************************************/ @@ -21,285 +17,534 @@ ***************************************************************************/ """ -from builtins import str -from builtins import range -from builtins import object -from collections import defaultdict import os.path +from datetime import datetime, timedelta -from qgis.PyQt.QtCore import QSize, Qt, QUrl, pyqtSignal +from qgis.PyQt.QtCore import QSize, Qt, QUrl, QVariant, QSettings from qgis.PyQt.QtGui import QPixmap, QCursor, QIcon, QColor, QDesktopServices -from qgis.PyQt.QtWidgets import QAction, QAbstractSpinBox, QInputDialog, QLineEdit +from qgis.PyQt.QtWidgets import ( + QAction, + QApplication, + QComboBox, + QInputDialog, + QLabel, + QLineEdit, +) from qgis.core import ( QgsCoordinateTransform, QgsCsException, + QgsExpression, + QgsFeature, + QgsField, + QgsGeometry, + QgsMapLayerType, + QgsMeshDatasetIndex, QgsPointXY, QgsProject, - QgsRasterBlock, QgsRaster, - QgsRasterDataProvider + QgsRasterDataProvider, + QgsRectangle, + QgsSpatialIndex, + QgsVectorLayer, ) -from qgis.gui import QgsDoubleSpinBox, QgsMapToolEmitPoint, QgsColorButton - -from .utils import is_number, icon_path, dtypes +from qgis.gui import (QgsDoubleSpinBox, QgsMapToolEmitPoint, QgsColorButton, QgsExpressionBuilderDialog, ) + +from .raster_handler import RasterHandler +from .selection_tool import RasterCellSelectionMapTool +from .serval_exp_functions import ( + interpolate_from_mesh, + intersecting_features_attr_average, + nearest_feature_attr_value, + nearest_pt_on_line_interpolate_z, +) +from .band_spin_boxes import BandBoxes +from .layer_select_dlg import LayerSelectDialog +from .raster_changes import RasterChanges +from .utils import is_number, icon_path, dtypes, get_logger from .user_communication import UserCommunication -try: - # QgsMapLayerType added in QGIS 3.8 - from qgis.core import QgsMapLayerType - raster_layer_type = QgsMapLayerType.RasterLayer -except ImportError: - raster_layer_type = 1 +DEBUG = False -class BandSpinBox(QgsDoubleSpinBox): - """Spinbox class for raster band value""" - - user_hit_enter = pyqtSignal() - - def __init__(self, parent=None): - super(BandSpinBox, self).__init__() +class Serval(object): - def keyPressEvent(self, event): - b = self.property("bandNr") - if event.key() == Qt.Key_Return or event.key() == Qt.Key_Enter: - if is_number(self.text().replace(',','.')): - self.setValue(float(self.text().replace(',', '.'))) - self.user_hit_enter.emit() - else: - QgsDoubleSpinBox.keyPressEvent(self, event) - + LINE_SELECTION = "line" + POLYGON_SELECTION = "polygon" + RGB = "RGB" + SINGLE_BAND = "Single band" -class Serval(object): def __init__(self, iface): self.iface = iface self.canvas = self.iface.mapCanvas() self.plugin_dir = os.path.dirname(__file__) self.uc = UserCommunication(iface, 'Serval') - self.mode = 'probe' - self.bands = None + self.load_settings() self.raster = None + self.handler = None + self.spin_boxes = None + self.exp_dlg = None + self.exp_builder = None + self.block_pts_layer = None self.px, self.py = [0, 0] self.last_point = QgsPointXY(0, 0) - self.undos = defaultdict(list) - self.redos = defaultdict(list) - self.qgis_project = QgsProject() + self.rbounds = None + self.changes = dict() # dict with rasters changes {raster_id: RasterChanges instance} + self.qgis_project = QgsProject.instance() + self.all_touched = None + self.selection_mode = None + self.spatial_index_time = dict() # {layer_id: creation time} + self.spatial_index = dict() # {layer_id: spatial index} + self.selection_layers_count = 1 + self.debug = DEBUG + self.logger = get_logger() if self.debug else None self.menu = u'Serval' self.actions = [] - self.toolbar = self.iface.addToolBar(u'Serval') - self.toolbar.setObjectName(u'Serval') - self.toolbar.setToolTip(u'Serval Toolbar') + self.toolbar = self.iface.addToolBar(u'Serval Main Toolbar') + self.toolbar.setObjectName(u'Serval Main Toolbar') + self.toolbar.setToolTip(u'Serval Main Toolbar') + + self.sel_toolbar = self.iface.addToolBar(u'Serval Selection Toolbar') + self.sel_toolbar.setObjectName(u'Serval Selection Toolbar') + self.sel_toolbar.setToolTip(u'Serval Selection Toolbar') # Map tools - self.probeTool = QgsMapToolEmitPoint(self.canvas) - self.probeTool.setObjectName('ServalProbeTool') - self.probeTool.setCursor(QCursor(QPixmap(icon_path('probe_tool.svg')), hotX=2, hotY=22)) - self.probeTool.canvasClicked.connect(self.point_clicked) - self.drawTool = QgsMapToolEmitPoint(self.canvas) - self.drawTool.setObjectName('ServalDrawTool') - self.drawTool.setCursor(QCursor(QPixmap(icon_path('draw_tool.svg')), hotX=2, hotY=22)) - self.drawTool.canvasClicked.connect(self.point_clicked) - self.gomTool = QgsMapToolEmitPoint(self.canvas) - self.gomTool.setObjectName('ServalGomTool') - self.gomTool.setCursor(QCursor(QPixmap(icon_path('gom_tool.svg')), hotX=5, hotY=19)) - self.gomTool.canvasClicked.connect(self.point_clicked) - - self.mColorButton = QgsColorButton() - icon1 = QIcon(icon_path('mIconColorBox.svg')) - self.mColorButton.setIcon(icon1) - self.mColorButton.setMinimumSize(QSize(40, 24)) - self.mColorButton.setMaximumSize(QSize(40, 24)) - self.mColorButton.colorChanged.connect(self.set_rgb_from_picker) - - self.b1SBox = BandSpinBox() - self.b2SBox = BandSpinBox() - self.b3SBox = BandSpinBox() - self.sboxes = [self.b1SBox, self.b2SBox, self.b3SBox] - for sb in self.sboxes: - sb.user_hit_enter.connect(self.change_cell_value_key) + self.probe_tool = QgsMapToolEmitPoint(self.canvas) + self.probe_tool.setObjectName('ServalProbeTool') + self.probe_tool.setCursor(QCursor(QPixmap(icon_path('probe_tool.svg')), hotX=2, hotY=22)) + self.probe_tool.canvasClicked.connect(self.point_clicked) + self.draw_tool = QgsMapToolEmitPoint(self.canvas) + self.draw_tool.setObjectName('ServalDrawTool') + self.draw_tool.setCursor(QCursor(QPixmap(icon_path('draw_tool.svg')), hotX=2, hotY=22)) + self.draw_tool.canvasClicked.connect(self.point_clicked) + self.selection_tool = RasterCellSelectionMapTool(self.iface, self.uc, self.raster, debug=self.debug) + self.selection_tool.setObjectName('RasterSelectionTool') + self.map_tool_btn = dict() # {map tool: button activating the tool} self.iface.currentLayerChanged.connect(self.set_active_raster) self.qgis_project.layersAdded.connect(self.set_active_raster) self.canvas.mapToolSet.connect(self.check_active_tool) - def initGui(self): + self.register_exp_functions() + + def load_settings(self): + """Return plugin settings dict - default values are overriden by user prefered values from QSettings.""" + self.default_settings = { + "undo_steps": {"value": 3, "vtype": int}, + } + self.settings = dict() + s = QSettings() + s.beginGroup("serval") + for k, v in self.default_settings.items(): + user_val = s.value(k, v["value"], v["vtype"]) + self.settings[k] = user_val + + def edit_settings(self): + """Open dialog with plugin settings.""" + s = QSettings() + s.beginGroup("serval") + k = "undo_steps" + cur_val = self.settings[k] + val_type = self.default_settings[k]["vtype"] + cur_steps = s.value(k, cur_val, val_type) + + label = 'Nr of Undo/Redo steps:' + steps, ok = QInputDialog.getInt(None, "Serval Settings", label, cur_steps) + if not ok: + return + if steps >= 0: + s.setValue("undo_steps", steps) + self.load_settings() + self.uc.show_info("Some new settings may require QGIS restart.") - # Menu and toolbar actions - self.add_action( + def initGui(self): + dummy = self.add_action( 'serval_icon.svg', - text=u'Show Serval Toolbar', + text=u'Show Serval Toolbars', add_to_menu=True, - add_to_toolbar=False, - callback=self.show_toolbar, - parent=self.iface.mainWindow()) + callback=self.show_toolbar, ) self.probe_btn = self.add_action( 'probe.svg', - text=u'Probing Mode', - whats_this=u'Probing Mode', - add_to_toolbar=True, + text="Probe raster", callback=self.activate_probing, - parent=self.iface.mainWindow()) + add_to_toolbar=self.toolbar, + checkable=True, ) + self.map_tool_btn[self.probe_tool] = self.probe_btn + + self.color_btn = QgsColorButton() + self.color_btn.setColor(Qt.gray) + self.color_btn.setMinimumSize(QSize(40, 24)) + self.color_btn.setMaximumSize(QSize(40, 24)) + self.toolbar.addWidget(self.color_btn) + self.color_picker_connection(connect=True) + self.color_btn.setDisabled(True) + + self.toolbar.addWidget(QLabel("Band:")) + self.bands_cbo = QComboBox() + self.bands_cbo.addItem("1", 1) + self.toolbar.addWidget(self.bands_cbo) + self.bands_cbo.currentIndexChanged.connect(self.update_active_bands) + self.bands_cbo.setDisabled(True) + + self.spin_boxes = BandBoxes() + self.toolbar.addWidget(self.spin_boxes) + self.spin_boxes.enter_hit.connect(self.apply_spin_box_values) self.draw_btn = self.add_action( 'draw.svg', - text=u'Drawing Mode', - whats_this=u'Drawing Mode', - add_to_toolbar=True, + text="Apply Value(s) To Single Cell", callback=self.activate_drawing, - parent=self.iface.mainWindow()) + add_to_toolbar=self.toolbar, + checkable=True, ) + self.map_tool_btn[self.draw_tool] = self.draw_btn - self.gom_btn = self.add_action( - 'gom.svg', - text=u'Set Raster Cell Value to NoData', - whats_this=u'Set Raster Cell Value to NoData', - add_to_toolbar=True, - callback=self.activate_gom, - parent=self.iface.mainWindow()) + self.apply_spin_box_values_btn = self.add_action( + 'apply_const_value.svg', + text="Apply Value(s) to Selection", + callback=self.apply_spin_box_values, + add_to_toolbar=self.toolbar, ) - self.checkable_tool_btns = [self.draw_btn, self.probe_btn, self.gom_btn] - - self.def_nodata_btn = self.add_action( - 'define_nodata.svg', - text=u'Define/Change Raster NoData Value', - whats_this=u'Define/Change Raster NoData Value', - add_to_toolbar=True, - callback=self.define_nodata, - parent=self.iface.mainWindow()) - - self.toolbar.addWidget(self.mColorButton) - - self.setup_spin_boxes() + self.gom_btn = self.add_action( + 'apply_nodata_value.svg', + text="Apply NoData to Selection", + callback=self.apply_nodata_value, + add_to_toolbar=self.toolbar, ) + + self.exp_dlg_btn = self.add_action( + 'apply_expression_value.svg', + text="Apply Expression Value To Selection", + callback=self.define_expression, + add_to_toolbar=self.toolbar, + checkable=False, ) + + self.low_pass_filter_btn = self.add_action( + 'apply_low_pass_filter.svg', + text="Apply Low-Pass 3x3 Filter To Selection", + callback=self.apply_low_pass_filter, + add_to_toolbar=self.toolbar, + checkable=False, ) + + self.set_nodata_btn = self.add_action( + 'set_nodata.svg', + text="Edit Raster NoData Values", + callback=self.set_nodata, + add_to_toolbar=self.toolbar, ) self.undo_btn = self.add_action( 'undo.svg', - text=u'Undo', - whats_this=u'Undo', - add_to_toolbar=True, + text="Undo", callback=self.undo, - parent=self.iface.mainWindow()) + add_to_toolbar=self.toolbar, ) self.redo_btn = self.add_action( 'redo.svg', - text=u'Redo', - whats_this=u'Redo', - add_to_toolbar=True, + text="Redo", callback=self.redo, - parent=self.iface.mainWindow()) + add_to_toolbar=self.toolbar, ) + + self.settings_btn = self.add_action( + 'edit_settings.svg', + text="Serval Settings", + callback=self.edit_settings, + add_to_toolbar=self.toolbar, ) self.show_help = self.add_action( 'help.svg', - text=u'Help', - whats_this=u'Help', - add_to_toolbar=True, + text="Help", add_to_menu=True, callback=self.show_website, - parent=self.iface.mainWindow()) - - self.set_active_raster() + add_to_toolbar=self.toolbar, ) + + # Selection Toolbar + + line_width_icon = QIcon(icon_path("line_width.svg")) + line_width_lab = QLabel() + line_width_lab.setPixmap(line_width_icon.pixmap(22, 12)) + self.sel_toolbar.addWidget(line_width_lab) + + self.line_width_sbox = QgsDoubleSpinBox() + self.line_width_sbox.setMinimumSize(QSize(50, 24)) + self.line_width_sbox.setMaximumSize(QSize(50, 24)) + # self.line_width_sbox.setButtonSymbols(QAbstractSpinBox.NoButtons) + self.line_width_sbox.setValue(1) + self.line_width_sbox.setMinimum(0.01) + self.line_width_sbox.setShowClearButton(False) + self.line_width_sbox.setToolTip("Selection Line Width") + self.line_width_sbox.valueChanged.connect(self.update_selection_tool) + + self.width_unit_cbo = QComboBox() + self.width_units = ("map units", "pixel width", "pixel height", "hairline",) + for u in self.width_units: + self.width_unit_cbo.addItem(u) + self.width_unit_cbo.setToolTip("Selection Line Width Unit") + self.sel_toolbar.addWidget(self.line_width_sbox) + self.sel_toolbar.addWidget(self.width_unit_cbo) + self.width_unit_cbo.currentIndexChanged.connect(self.update_selection_tool) + + self.line_select_btn = self.add_action( + 'select_line.svg', + text="Select Raster Cells by Line", + callback=self.activate_line_selection, + add_to_toolbar=self.sel_toolbar, + checkable=True, ) + + self.polygon_select_btn = self.add_action( + 'select_polygon.svg', + text="Select Raster Cells by Polygon", + callback=self.activate_polygon_selection, + add_to_toolbar=self.sel_toolbar, + checkable=True, ) + + self.selection_from_layer_btn = self.add_action( + 'select_from_layer.svg', + text="Create Selection From Layer", + callback=self.selection_from_layer, + add_to_toolbar=self.sel_toolbar, ) + + self.selection_to_layer_btn = self.add_action( + 'selection_to_layer.svg', + text="Create Memory Layer From Selection", + callback=self.selection_to_layer, + add_to_toolbar=self.sel_toolbar, ) + + self.clear_selection_btn = self.add_action( + 'clear_selection.svg', + text="Clear selection", + callback=self.clear_selection, + add_to_toolbar=self.sel_toolbar, ) + + self.toggle_all_touched_btn = self.add_action( + 'all_touched.svg', + text="Toggle All Touched Get Selected", + callback=self.toggle_all_touched, + checkable=True, checked=True, + add_to_toolbar=self.sel_toolbar, ) + self.all_touched = True + + self.enable_toolbar_actions(enable=False) self.check_undo_redo_btns() - def add_action( - self, - icon_name, - text, - callback, - enabled_flag=True, - add_to_menu=False, - add_to_toolbar=True, - status_tip=None, - whats_this=None, - parent=None): + def add_action(self, icon_name, callback=None, text="", enabled_flag=True, add_to_menu=False, add_to_toolbar=None, + status_tip=None, whats_this=None, checkable=False, checked=False): icon = QIcon(icon_path(icon_name)) - action = QAction(icon, text, parent) + action = QAction(icon, text, self.iface.mainWindow()) action.triggered.connect(callback) action.setEnabled(enabled_flag) + action.setCheckable(checkable) + action.setChecked(checked) if status_tip is not None: action.setStatusTip(status_tip) - if whats_this is not None: action.setWhatsThis(whats_this) - - if add_to_toolbar: - self.toolbar.addAction(action) - + if add_to_toolbar is not None: + add_to_toolbar.addAction(action) if add_to_menu: - self.iface.addPluginToMenu( - self.menu, - action) + self.iface.addPluginToMenu(self.menu, action) self.actions.append(action) return action def unload(self): - self.iface.actionPan().trigger() - + self.changes = None + if self.selection_tool: + self.selection_tool.reset() + if self.spin_boxes is not None: + self.spin_boxes.remove_spinboxes() for action in self.actions: - self.iface.removePluginMenu( - u'Serval', - action) + self.iface.removePluginMenu('Serval', action) self.iface.removeToolBarIcon(action) - del self.toolbar + del self.sel_toolbar + self.iface.actionPan().trigger() + self.unregister_exp_functions() def show_toolbar(self): if self.toolbar: self.toolbar.show() + self.sel_toolbar.show() - def check_active_tool(self, tool): - try: - if not tool.objectName() in ['ServalDrawTool', 'ServalProbeTool', 'ServalGomTool']: - self.probe_btn.setChecked(False) - self.draw_btn.setChecked(False) - self.gom_btn.setChecked(False) - except AttributeError: - pass + @staticmethod + def register_exp_functions(): + QgsExpression.registerFunction(nearest_feature_attr_value) + QgsExpression.registerFunction(nearest_pt_on_line_interpolate_z) + QgsExpression.registerFunction(intersecting_features_attr_average) + QgsExpression.registerFunction(interpolate_from_mesh) - def set_checked_tool_btn(self, cur_tool_btn): - for btn in self.checkable_tool_btns: - if btn == cur_tool_btn: - btn.setChecked(True) + @staticmethod + def unregister_exp_functions(): + QgsExpression.unregisterFunction('nearest_feature_attr_value') + QgsExpression.unregisterFunction('nearest_pt_on_line_interpolate_z') + QgsExpression.unregisterFunction('intersecting_features_attr_average') + QgsExpression.unregisterFunction('interpolate_from_mesh') + + def uncheck_all_btns(self): + self.probe_btn.setChecked(False) + self.draw_btn.setChecked(False) + self.gom_btn.setChecked(False) + self.line_select_btn.setChecked(False) + self.polygon_select_btn.setChecked(False) + + def check_active_tool(self, cur_tool): + self.uncheck_all_btns() + if cur_tool in self.map_tool_btn: + self.map_tool_btn[cur_tool].setChecked(True) + if cur_tool == self.selection_tool: + if self.selection_mode == self.LINE_SELECTION: + self.line_select_btn.setChecked(True) else: - btn.setChecked(False) + self.polygon_select_btn.setChecked(True) def activate_probing(self): self.mode = 'probe' - self.canvas.setMapTool(self.probeTool) - self.set_checked_tool_btn(self.probe_btn) + self.canvas.setMapTool(self.probe_tool) + + def define_expression(self): + if not self.selection_tool.selected_geometries: + self.uc.bar_warn("No selection for raster layer. Select some cells and retry...") + return + self.handler.select(self.selection_tool.selected_geometries, all_touched_cells=self.all_touched) + self.handler.create_cell_pts_layer() + if self.handler.cell_pts_layer.featureCount() == 0: + self.uc.bar_warn("No selection for raster layer. Select some cells and retry...") + return + self.exp_dlg = QgsExpressionBuilderDialog(self.handler.cell_pts_layer) + self.exp_builder = self.exp_dlg.expressionBuilder() + self.exp_dlg.accepted.connect(self.apply_exp_value) + self.exp_dlg.show() + + def apply_exp_value(self): + if not self.exp_dlg.expressionText() or not self.exp_builder.isExpressionValid(): + return + QApplication.setOverrideCursor(Qt.WaitCursor) + exp = self.exp_dlg.expressionText() + idx = self.handler.cell_pts_layer.addExpressionField(exp, QgsField('exp_val', QVariant.Double)) + self.handler.exp_field_idx = idx + self.handler.write_block() + QApplication.restoreOverrideCursor() + self.raster.triggerRepaint() def activate_drawing(self): self.mode = 'draw' - self.canvas.setMapTool(self.drawTool) - self.set_checked_tool_btn(self.draw_btn) - - def activate_gom(self): - self.mode = 'gom' - self.canvas.setMapTool(self.gomTool) - self.set_checked_tool_btn(self.gom_btn) - - def setup_spin_boxes(self): - - for sbox in self.sboxes: - sbox.setMinimumSize(QSize(60, 25)) - sbox.setMaximumSize(QSize(60, 25)) - sbox.setAlignment(Qt.AlignLeft) - sbox.setButtonSymbols(QAbstractSpinBox.NoButtons) - sbox.setKeyboardTracking(False) - sbox.setShowClearButton(False) - sbox.setExpressionsEnabled(False) - sbox.setStyleSheet("") - self.toolbar.addWidget(sbox) + self.canvas.setMapTool(self.draw_tool) + + def get_cur_line_width(self): + width_coef = { + "map units": 1., + "pixel width": self.raster.rasterUnitsPerPixelX(), + "pixel height": self.raster.rasterUnitsPerPixelY(), + "hairline": 0.000001, + } + return self.line_width_sbox.value() * width_coef[self.width_unit_cbo.currentText()] + + def set_selection_tool(self, mode): + if self.raster is None: + self.uc.bar_warn("Select a raster layer") + return + self.selection_mode = mode + self.selection_tool.init_tool(self.raster, mode=self.selection_mode, line_width=self.get_cur_line_width()) + self.selection_tool.set_prev_tool(self.canvas.mapTool()) + self.canvas.setMapTool(self.selection_tool) + + def activate_line_selection(self): + self.set_selection_tool(self.LINE_SELECTION) + + def activate_polygon_selection(self): + self.set_selection_tool(self.POLYGON_SELECTION) + + def update_selection_tool(self): + """Reactivate the selection tool with updated line width and units.""" + if self.selection_mode == self.LINE_SELECTION: + self.activate_line_selection() + elif self.selection_mode == self.POLYGON_SELECTION: + self.activate_polygon_selection() + else: + pass + + def apply_values(self, new_values): + QApplication.setOverrideCursor(Qt.WaitCursor) + self.handler.select(self.selection_tool.selected_geometries, all_touched_cells=self.all_touched) + self.handler.write_block(new_values) + QApplication.restoreOverrideCursor() + self.raster.triggerRepaint() + + def apply_values_single_cell(self, new_vals): + """Create single cell selection and apply the new values.""" + cpt = self.last_point + col, row = self.handler.point_to_index([cpt.x(), cpt.y()]) + px, py = self.handler.index_to_point(row, col, upper_left=False) + d = 0.001 + bbox = QgsRectangle(px - d, py - d, px + d, py + d) + if self.logger: + self.logger.debug(f"Changing single cell in {bbox}") + QApplication.setOverrideCursor(Qt.WaitCursor) + self.handler.select([QgsGeometry.fromRect(bbox)], all_touched_cells=False) + self.handler.write_block(new_vals) + QApplication.restoreOverrideCursor() + self.raster.triggerRepaint() + + def apply_spin_box_values(self): + if not self.selection_tool.selected_geometries: + return + self.apply_values(self.spin_boxes.get_values()) + + def apply_nodata_value(self): + if not self.selection_tool.selected_geometries: + return + self.apply_values(self.handler.nodata_values) + + def apply_low_pass_filter(self): + QApplication.setOverrideCursor(Qt.WaitCursor) + self.handler.select(self.selection_tool.selected_geometries, all_touched_cells=self.all_touched) + self.handler.write_block(low_pass_filter=True) + QApplication.restoreOverrideCursor() + self.raster.triggerRepaint() + + def clear_selection(self): + if self.selection_tool: + self.selection_tool.clear_all_selections() + + def selection_from_layer(self): + """Create a new selection from layer.""" + self.selection_tool.init_tool(self.raster, mode=self.POLYGON_SELECTION, line_width=self.get_cur_line_width()) + dlg = LayerSelectDialog() + if not dlg.exec_(): + return + cur_layer = dlg.cbo.currentLayer() + if not cur_layer.type() == QgsMapLayerType.VectorLayer: + return + self.selection_tool.selection_from_layer(cur_layer) + + def selection_to_layer(self): + """Create a memory layer from current selection""" + geoms = self.selection_tool.selected_geometries + if geoms is None or not self.raster: + return + crs_str = self.raster.crs().toProj() + nr = self.selection_layers_count + self.selection_layers_count += 1 + mlayer = QgsVectorLayer(f"Polygon?crs={crs_str}&field=fid:int", f"Raster selection {nr}", "memory") + fields = mlayer.dataProvider().fields() + features = [] + for i, geom in enumerate(geoms): + feat = QgsFeature(fields) + feat["fid"] = i + 1 + feat.setGeometry(geom) + features.append(feat) + mlayer.dataProvider().addFeatures(features) + self.qgis_project.addMapLayer(mlayer) + + def toggle_all_touched(self): + """Toggle selection mode.""" + # button is toggled automatically when clicked, just update the attribute + self.all_touched = self.toggle_all_touched_btn.isChecked() def point_clicked(self, point=None, button=None): - # check if active layer is raster if self.raster is None: self.uc.bar_warn("Choose a raster to work with...", dur=3) return - - # check if coordinates trasformation is required canvas_srs = self.iface.mapCanvas().mapSettings().destinationCrs() if point is None: pos = self.last_point @@ -309,322 +554,273 @@ def point_clicked(self, point=None, button=None): try: pos = srs_transform.transform(point) except QgsCsException as err: - self.uc.bar_warn( - "Point coordinates transformation failed! Check the raster projection:\n\n{}".format(repr(err)), - dur=5) + self.uc.show_warn( + "Point coordinates transformation failed! Check the raster projection:\n\n{}".format(repr(err))) return else: pos = QgsPointXY(point.x(), point.y()) - - # keep last clicked point self.last_point = pos - - # check if the point is within active raster bounds - if self.rbounds[0] <= pos.x() <= self.rbounds[2]: - self.px = int((pos.x() - self.rbounds[0]) / self.raster.rasterUnitsPerPixelX()) # - self.gt[0]) / self.gt[1]) - else: - self.uc.bar_info("Out of x bounds", dur=2) - return + ident_vals = self.handler.provider.identify(pos, QgsRaster.IdentifyFormatValue).results() + cur_vals = list(ident_vals.values()) - if self.rbounds[1] <= pos.y() <= self.rbounds[3]: - self.py = int((self.rbounds[3] - pos.y()) / self.raster.rasterUnitsPerPixelY()) # - self.gt[3]) / self.gt[5]) - else: - self.uc.bar_info("Out of y bounds", dur=2) + # check if the point is within active raster extent + if not self.rbounds[0] <= pos.x() <= self.rbounds[2]: + self.uc.bar_info("Out of x bounds", dur=3) return - - # probe current raster value, dict: band_nr -> value - vals = self.rdp.identify(pos, QgsRaster.IdentifyFormatValue).results() - - # for rasters having more that 3 bands, ignore other than 1-3 - bands_to_ignore = [i for i in vals.keys() if i > 3] - for band_nr in bands_to_ignore: - del vals[band_nr] - - # data types for each band - dtypes = [] - - for nr in range(1, min(4, self.band_count + 1)): - # bands data type - dtypes.append(self.bands[nr]['qtype']) - - # check if nodata is defined - if self.mode == 'gom' and self.bands[nr]['nodata'] is None: - msg = 'NODATA value is not defined for one of the raster\'s bands.\n' - msg += 'Please define it in raster properties dialog!' - self.uc.show_warn(msg) - return - - # if in probing mode, set band's spinbox value - if self.mode == 'probe': - val = vals[nr] if is_number(vals[nr]) else self.bands[nr]['nodata'] - self.bands[nr]['sbox'].setValue(val) - self.bands[nr]['sbox'].setFocus() - self.bands[nr]['sbox'].selectAll() - - if not self.mode == 'probe': - - old_vals = [v if v is not None else self.bands[k]['nodata'] for k, v in sorted(vals.items())] - if self.mode == 'gom': - temp_vals = [self.bands[nr]['nodata'] for nr in sorted(vals.keys())] - new_vals = [int(v) if dtypes[i] < 6 else float(v) for i, v in enumerate(temp_vals)] - else: - temp_vals = [self.bands[nr]['sbox'].value() for nr in sorted(vals.keys())] - new_vals = [int(v) if dtypes[i] < 6 else float(v) for i, v in enumerate(temp_vals)] - - # store all bands' changes to undo list - self.undos[self.raster.id()].append([old_vals, new_vals, self.px, self.py, pos]) - - # write the new cell value(s) - self.change_cell_value(new_vals) - - if self.band_count > 2: - self.mColorButton.setColor(QColor( - self.bands[1]['sbox'].value(), - self.bands[2]['sbox'].value(), - self.bands[3]['sbox'].value() - )) - - def set_rgb_from_picker(self, c): - """Set bands spinboxes values after color change in the color picker""" - self.bands[1]['sbox'].setValue(c.red()) - self.bands[2]['sbox'].setValue(c.green()) - self.bands[3]['sbox'].setValue(c.blue()) - - def change_cell_value(self, vals, x=None, y=None): - """Save new bands values to data provider""" - - if not self.rdp.isEditable(): - success = self.rdp.setEditable(True) - if not success: - self.uc.show_warn('QGIS can\'t modify this type of raster') - return - - if not x: - x = self.px - y = self.py - - for nr in range(1, min(4, self.band_count + 1)): - rblock = QgsRasterBlock(self.bands[nr]['qtype'], 1, 1) - rblock.setValue(0, 0, vals[nr - 1]) - success = self.rdp.writeBlock(rblock, nr, x, y) - if not success: - self.uc.show_warn('QGIS can\'t modify this type of raster') - return - - self.rdp.setEditable(False) - self.raster.triggerRepaint() - - # prepare raster for next actions - self.prepare_raster(True) - self.check_undo_redo_btns() - - def change_cell_value_key(self): - """Change cell value after user changes band's spinbox value and hits Enter key""" - if self.last_point: - pm = self.mode - self.mode = 'draw' - self.point_clicked() - self.mode = pm - - def undo(self): - if self.undos[self.raster.id()]: - data = self.undos[self.raster.id()].pop() - self.redos[self.raster.id()].append(data) - else: + if not self.rbounds[1] <= pos.y() <= self.rbounds[3]: + self.uc.bar_info("Out of y bounds", dur=3) return - self.change_cell_value(data[0], data[2], data[3]) - def redo(self): - if self.redos[self.raster.id()]: - data = self.redos[self.raster.id()].pop() - self.undos[self.raster.id()].append(data) + if self.mode == 'draw': + new_vals = self.spin_boxes.get_values() + self.apply_values_single_cell(new_vals) else: - return - self.change_cell_value(data[1], data[2], data[3]) + self.spin_boxes.set_values(cur_vals) + if 2 < self.handler.bands_nr < 5: + self.color_picker_connection(connect=False) + self.color_btn.setColor(QColor(*self.spin_boxes.get_values()[:4])) + self.color_picker_connection(connect=True) - def define_nodata(self): - """Define and write a new NoData value to raster file""" + def set_values_from_picker(self, c): + """Set bands spinboxes values after color change in the color picker""" + values = None + if self.handler.bands_nr > 2: + values = [c.red(), c.green(), c.blue()] + if self.handler.bands_nr == 4: + values.append(c.alpha()) + if values: + self.spin_boxes.set_values(values) + + def set_nodata(self): + """Set NoData value(s) for each band of current raster.""" if not self.raster: self.uc.bar_warn('Select a raster layer to define/change NoData value!') return - - # check if user defined additional NODATA value - if self.rdp.userNoDataValues(1): + if self.handler.provider.userNoDataValues(1): note = '\nNote: there is a user defined NODATA value.\nCheck the raster properties (Transparency).' else: note = '' - # first band data type - dt = self.rdp.dataType(1) + dt = self.handler.provider.dataType(1) # current NODATA value - if self.rdp.sourceHasNoDataValue(1): - cur_nodata = self.rdp.sourceNoDataValue(1) + if self.handler.provider.sourceHasNoDataValue(1): + cur_nodata = self.handler.provider.sourceNoDataValue(1) if dt < 6: cur_nodata = '{0:d}'.format(int(float(cur_nodata))) else: cur_nodata = '' label = 'Define/change raster NODATA value.\n\n' - label += 'Raster data type: {}.{}'.format(dtypes[dt]['name'], note) - nd, ok = QInputDialog.getText(None, "Define NODATA Value", - label, QLineEdit.Normal, str(cur_nodata)) + label += 'Raster src_data type: {}.{}'.format(dtypes[dt]['name'], note) + nd, ok = QInputDialog.getText(None, "Define NODATA Value", label, QLineEdit.Normal, str(cur_nodata)) if not ok: return - if not is_number(nd): self.uc.show_warn('Wrong NODATA value!') return - new_nodata = int(nd) if dt < 6 else float(nd) # set the NODATA value for each band res = [] - for nr in range(1, min(4, self.band_count + 1)): - res.append(self.rdp.setNoDataValue(nr, new_nodata)) - self.rdp.sourceHasNoDataValue(nr) + for nr in self.handler.bands_range: + res.append(self.handler.provider.setNoDataValue(nr, new_nodata)) + self.handler.provider.sourceHasNoDataValue(nr) if False in res: self.uc.show_warn('Setting new NODATA value failed!') else: - self.uc.bar_info('Succesful setting new NODATA values!', dur=2) + self.uc.bar_info('Successful setting new NODATA values!', dur=2) - self.prepare_raster() + self.set_active_raster() self.raster.triggerRepaint() def check_undo_redo_btns(self): - """Enable/Disable undo and redo buttons based on availability of undo/redo steps""" - try: - if len(self.undos[self.raster.id()]) == 0: - self.undo_btn.setDisabled(True) - else: - self.undo_btn.setEnabled(True) - except: - self.undo_btn.setDisabled(True) - - try: - if len(self.redos[self.raster.id()]) == 0: - self.redo_btn.setDisabled(True) - else: - self.redo_btn.setEnabled(True) - except: - self.redo_btn.setDisabled(True) - - def disable_toolbar_actions(self): - # disable all toolbar actions but Help (for vectors and unsupported rasters) - for action in self.actions: - action.setDisabled(True) + """Enable/Disable undo and redo buttons based on availability of undo/redo for current raster.""" + self.undo_btn.setDisabled(True) + self.redo_btn.setDisabled(True) + if self.raster is None or self.raster.id() not in self.changes: + return + changes = self.changes[self.raster.id()] + if changes.nr_undos() > 0: + self.undo_btn.setEnabled(True) + if changes.nr_redos() > 0: + self.redo_btn.setEnabled(True) + + def enable_toolbar_actions(self, enable=True): + """Enable / disable all toolbar actions but Help (for vectors and unsupported rasters)""" + for widget in self.actions + [self.width_unit_cbo, self.line_width_sbox]: + widget.setEnabled(enable) + self.spin_boxes.enable(enable) + self.settings_btn.setEnabled(True) self.show_help.setEnabled(True) - def check_layer(self, layer): + @staticmethod + def check_layer(layer): """Check if we can work with the raster""" - if layer == None \ - or not layer.isValid() \ - or not layer.type() == raster_layer_type \ - or not (layer.dataProvider().capabilities() & QgsRasterDataProvider.Create) \ - or layer.crs() is None: + if layer is not None and layer.type() > 1: return False - else: + if layer is not None and all([ + layer.isValid(), + layer.type() != QgsMapLayerType.MeshLayer, + layer.type() == QgsMapLayerType.RasterLayer, + (layer.dataProvider().capabilities() & QgsRasterDataProvider.Create), + layer.crs() is not None] + ): return True + else: + return False - def set_active_raster(self): - """Active layer has change - check if it is a raster layer and prepare it for the plugin""" - - if self.bands: - self.bands = None - - for sbox in self.sboxes: - sbox.setValue(0) + def set_bands_cbo(self): + self.bands_cbo.currentIndexChanged.disconnect(self.update_active_bands) + self.bands_cbo.clear() + for band in self.handler.bands_range: + self.bands_cbo.addItem(f"{band}", [band]) + if self.handler.bands_nr > 1: + self.bands_cbo.addItem(self.RGB, [1, 2, 3]) + self.bands_cbo.setCurrentIndex(0) + self.bands_cbo.currentIndexChanged.connect(self.update_active_bands) + + def update_active_bands(self, idx): + bands = self.bands_cbo.currentData() + self.handler.active_bands = bands + self.spin_boxes.create_spinboxes(bands, self.handler.data_types, self.handler.nodata_values) + self.color_btn.setEnabled(len(bands) > 1) + self.exp_dlg_btn.setEnabled(len(bands) == 1) + def set_active_raster(self): + """Active layer has changed - check if it is a raster layer and prepare it for the plugin""" + old_spin_boxes_values = self.spin_boxes.get_values() layer = self.iface.activeLayer() - if self.check_layer(layer): self.raster = layer - self.rdp = layer.dataProvider() - self.band_count = layer.bandCount() - - # is data type supported? - supported = True - for nr in range(1, min(4, self.band_count + 1)): - if self.rdp.dataType(nr) == 0 or self.rdp.dataType(nr) > 7: - t = dtypes[self.rdp.dataType(nr)]['name'] - supported = False - + canvas_srs = self.iface.mapCanvas().mapSettings().destinationCrs() + self.handler = RasterHandler(self.raster, canvas_srs, self.uc, self.debug) + supported, unsupported_type = self.handler.write_supported() if supported: - # enable all toolbar actions - for action in self.actions: - action.setEnabled(True) - # if raster properties change, get them (refeshes view) - self.raster.rendererChanged.connect(self.prepare_raster) - - self.prepare_raster(supported) - - # not supported data type + self.enable_toolbar_actions() + self.set_bands_cbo() + self.spin_boxes.create_spinboxes(self.handler.active_bands, + self.handler.data_types, self.handler.nodata_values) + if self.handler.bands_nr == len(old_spin_boxes_values): + self.spin_boxes.set_values(old_spin_boxes_values) + self.bands_cbo.setEnabled(self.handler.bands_nr > 1) + self.rbounds = self.raster.extent().toRectF().getCoords() + self.handler.raster_changed.connect(self.add_to_undo) + if self.raster.id() not in self.changes: + self.changes[self.raster.id()] = RasterChanges(nr_to_keep=self.settings["undo_steps"]) else: - msg = 'The raster data type is: {}.'.format(t) - msg += '\nServal can\'t work with it, sorry!' + msg = f"The raster has unsupported src_data type: {unsupported_type}" + msg += "\nServal can't work with it, sorry..." self.uc.show_warn(msg) + self.enable_toolbar_actions(enable=False) self.reset_raster() - # it is not a supported raster layer else: + # unsupported raster + self.enable_toolbar_actions(enable=False) self.reset_raster() self.check_undo_redo_btns() - def reset_raster(self): - self.raster = None - self.mColorButton.setDisabled(True) - self.prepare_raster(False) + def add_to_undo(self, change): + """Add the old and new blocks to undo stack.""" + self.changes[self.raster.id()].add_change(change) + self.check_undo_redo_btns() + if self.logger: + self.logger.debug(self.get_undo_redo_values()) - def prepare_raster(self, supported=True): - """Open raster using GDAL if it is supported""" + def get_undo_redo_values(self): + changes = self.changes[self.raster.id()] + return f"nr undos: {changes.nr_undos()}, redos: {changes.nr_redos()}" - # reset bands' spin boxes - for i, sbox in enumerate(self.sboxes): - sbox.setProperty('bandNr', i + 1) - sbox.setDisabled(True) - - if not supported: - return + def undo(self): + undo_data = self.changes[self.raster.id()].undo() + self.handler.write_block_undo(undo_data) + self.raster.triggerRepaint() + self.check_undo_redo_btns() - if self.band_count > 2: - self.mColorButton.setEnabled(True) + def redo(self): + redo_data = self.changes[self.raster.id()].redo() + self.handler.write_block_undo(redo_data) + self.raster.triggerRepaint() + self.check_undo_redo_btns() + + def reset_raster(self): + self.raster = None + self.color_btn.setDisabled(True) + + def color_picker_connection(self, connect=True): + if connect: + self.color_btn.colorChanged.connect(self.set_values_from_picker) else: - self.mColorButton.setDisabled(True) - - extent = self.raster.extent() - self.rbounds = extent.toRectF().getCoords() - - self.bands = {} - for nr in range(1, min(4, self.band_count + 1)): - self.bands[nr] = {} - self.bands[nr]['sbox'] = self.sboxes[nr - 1] - - # NODATA - if self.rdp.sourceHasNoDataValue(nr): # source nodata value? - self.bands[nr]['nodata'] = self.rdp.sourceNoDataValue(nr) - # use the src nodata - self.rdp.setUseSourceNoDataValue(nr, True) - # no nodata defined in the raster source - else: - # check if user defined any nodata values - if self.rdp.userNoDataValues(nr): - # get min nodata value from the first user nodata range - nd_ranges = self.rdp.userNoDataValues(nr) - self.bands[nr]['nodata'] = nd_ranges[0].min() - else: - # leave nodata undefined - self.bands[nr]['nodata'] = None - - # enable band's spin box - self.bands[nr]['sbox'].setEnabled(True) - # get bands data type - dt = self.bands[nr]['qtype'] = self.rdp.dataType(nr) - # set spin boxes properties - self.bands[nr]['sbox'].setMinimum(dtypes[dt]['min']) - self.bands[nr]['sbox'].setMaximum(dtypes[dt]['max']) - self.bands[nr]['sbox'].setDecimals(dtypes[dt]['dig']) + self.color_btn.colorChanged.disconnect(self.set_values_from_picker) @staticmethod def show_website(): QDesktopServices.openUrl(QUrl('https://github.com/erpas/serval/wiki')) + + def recreate_spatial_index(self, layer): + """Check if spatial index exists for the layer and if it is relatively old and eventually recreate it.""" + ctime = self.spatial_index_time[layer.id()] if layer.id() in self.spatial_index_time else None + if ctime is None or datetime.now() - ctime > timedelta(seconds=30): + self.spatial_index = QgsSpatialIndex(layer.getFeatures(), None, QgsSpatialIndex.FlagStoreFeatureGeometries) + self.spatial_index_time[layer.id()] = datetime.now() + + def get_nearest_feature(self, pt_feat, vlayer_id): + """Given the point feature, return nearest feature from vlayer.""" + vlayer = self.qgis_project.mapLayer(vlayer_id) + self.recreate_spatial_index(vlayer) + ptxy = pt_feat.geometry().asPoint() + near_fid = self.spatial_index.nearestNeighbor(ptxy)[0] + return vlayer.getFeature(near_fid) + + def nearest_feature_attr_value(self, pt_feat, vlayer_id, attr_name): + """Find nearest feature to pt_feat and return its attr_name attribute value.""" + near_feat = self.get_nearest_feature(pt_feat, vlayer_id) + return near_feat[attr_name] + + def nearest_pt_on_line_interpolate_z(self, pt_feat, vlayer_id): + """Find nearest line feature to pt_feat and interpolate z value from vertices.""" + near_feat = self.get_nearest_feature(pt_feat, vlayer_id) + near_geom = near_feat.geometry() + closest_pt_dist = near_geom.lineLocatePoint(pt_feat.geometry()) + closest_pt = near_geom.interpolate(closest_pt_dist) + return closest_pt.get().z() + + def intersecting_features_attr_average(self, pt_feat, vlayer_id, attr_name, only_center): + """ + Find all features intersecting current feature (cell center, or raster cell polygon) and calculate average + value of their attr_name attribute. + """ + vlayer = self.qgis_project.mapLayer(vlayer_id) + self.recreate_spatial_index(vlayer) + ptxy = pt_feat.geometry().asPoint() + pt_x, pt_y = ptxy.x(), ptxy.y() + dxy = 0.001 + half_pix_x = self.handler.pixel_size_x / 2. + half_pix_y = self.handler.pixel_size_y / 2. + if only_center: + cell = QgsRectangle(pt_x, pt_y, pt_x + dxy, pt_y + dxy) + else: + cell = QgsRectangle(pt_x - half_pix_x, pt_y - half_pix_y, + pt_x + half_pix_x, pt_y + half_pix_y) + inter_fids = self.spatial_index.intersects(cell) + values = [] + for fid in inter_fids: + feat = vlayer.getFeature(fid) + if not feat.geometry().intersects(cell): + continue + val = feat[attr_name] + if not is_number(val): + continue + values.append(val) + if len(values) == 0: + return None + return sum(values) / float(len(values)) + + def interpolate_from_mesh(self, pt_feat, mesh_layer_id, group, dataset): + """Interpolate from mesh.""" + mesh_layer = self.qgis_project.mapLayer(mesh_layer_id) + ptxy = pt_feat.geometry().asPoint() + dataset_val = mesh_layer.datasetValue(QgsMeshDatasetIndex(group, dataset), ptxy) + return dataset_val.scalar() diff --git a/Serval/serval_exp_functions.py b/Serval/serval_exp_functions.py new file mode 100644 index 0000000..d246544 --- /dev/null +++ b/Serval/serval_exp_functions.py @@ -0,0 +1,137 @@ +from qgis.core import * +from qgis.utils import plugins + + +@qgsfunction(args='auto', group='Serval', usesgeometry=True) +def nearest_feature_attr_value(vlayer_id, attr_name, feature, parent): + """ + Find nearest feature from vector layer and return its attribute value. + +

Syntax

+ + nearest_feature_attr_value ( + vlayer_id, + attr_name ) + +
+ +

Arguments

+
+ + + +
vlayer_idlinestring vector layer id - get it from Map Layers variables group
attr_namename of attribute with the value to get
+
+ +

Examples

+
+ """ + plugin = plugins["Serval"] + return plugin.nearest_feature_attr_value(feature, vlayer_id, attr_name) + + +@qgsfunction(args='auto', group='Serval', usesgeometry=True) +def nearest_pt_on_line_interpolate_z(vlayer_id, feature, parent): + """ + Find nearest feature from a linestring vector layer and interpolate z value at point closest to current geometry. + +

Syntax

+ + nearest_pt_on_line_interpolate_z ( vlayer_id ) + +
+ +

Arguments

+
+ + +
vlayer_idlinestring vector layer id - get it from Map Layers variables group
+
+ +

Examples

+
+ """ + plugin = plugins["Serval"] + return plugin.nearest_pt_on_line_interpolate_z(feature, vlayer_id) + + +@qgsfunction(args='auto', group='Serval', usesgeometry=True) +def intersecting_features_attr_average(vlayer_id, attr_name, only_center, feature, parent): + """ + Find feature(s) intersecting current feature (raster cell for Serval) and calculate arithmetic average of their attribute values. + +

Syntax

+ + intersecting_features_attr_average ( vlayer_id, + attr_name, + only_center ) + +
+ +

Arguments

+
+ + + + +
vlayer_ida vector layer id - get it from Map Layers variables group.
attr_namename of attribute with the value to get.
only_centerboolean, if True only cell center is used to find intersecting features. If False, whole raster cell is used.
+
+ +

Examples

+
+ """ + plugin = plugins["Serval"] + return plugin.intersecting_features_attr_average(feature, vlayer_id, attr_name, only_center) + + +@qgsfunction(args='auto', group='Serval', usesgeometry=True) +def interpolate_from_mesh(mlayer_id, group, dataset, feature, parent): + """ + Interpolate value from mesh layer. + +

Syntax

+ + interpolate_from_mesh ( mlayer_id, + group, + dataset ) + +
+ +

Arguments

+
+ + + + +
mlayer_ida mesh layer id - get it from Map Layers variables group.
group dataset group index, an integer.
dataset dataset index in the group, an integer.
+
+ +

Examples

+
+ """ + plugin = plugins["Serval"] + return plugin.interpolate_from_mesh(feature, mlayer_id, group, dataset) diff --git a/Serval/user_communication.py b/Serval/user_communication.py index 8b5ed46..4da9fd3 100644 --- a/Serval/user_communication.py +++ b/Serval/user_communication.py @@ -49,3 +49,6 @@ def bar_warn(self, msg, dur=5): def bar_info(self, msg, dur=5): self.iface.messageBar().pushMessage(self.context, msg, duration=dur) + + def clear_bar_messages(self): + self.iface.messageBar().clearWidgets() diff --git a/Serval/utils.py b/Serval/utils.py index d929d2c..2a26249 100644 --- a/Serval/utils.py +++ b/Serval/utils.py @@ -1,28 +1,5 @@ -# -*- coding: utf-8 -*- -""" -/*************************************************************************** - Serval, A QGIS plugin - - - Map tools for manipulating raster cell values - - begin : 2015-12-30 - copyright : (C) 2019 Radosław Pasiok for Lutra Consulting Ltd. - email : info@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. * - * * - ***************************************************************************/ -""" - import os.path - +from qgis.core import QgsSpatialIndexKDBush dtypes = { 0: {'name': 'UnknownDataType'}, @@ -60,3 +37,51 @@ def is_number(s): def icon_path(icon_filename): plugin_dir = os.path.dirname(__file__) return os.path.join(plugin_dir, 'icons', icon_filename) + + +def get_logger(): + import logging + from datetime import date + logger = logging.getLogger(f"Serval") + if not logger.hasHandlers(): + logger.setLevel(logging.DEBUG) + file_formatter = logging.Formatter("{asctime}: {message} [{filename}]", datefmt="%Y-%m-%d %H:%M:%S", style="{") + logfilename = os.path.join(os.path.dirname(os.path.abspath(__file__)), "logs", f"serval_{date.today()}.log") + fh = logging.FileHandler(logfilename) + fh.setFormatter(file_formatter) + logger.addHandler(fh) + return logger + + +def low_pass_filtered(block, row, col, nodata_value, nodata_mode=False): + """ + Return low-pass filtered (3x3) value of raster cell from the block at (row, col). + Cells at the edge of block are not filtered. + Cells having originally nodata are not modified either. + If nodata_mode is True, the nodata value will be return if any neighbor has nodata value. Otherwise, neighboring + nodata cells are ignored. + """ + org_val = block.value(row, col) + if org_val == nodata_value: + return nodata_value + max_row = block.height() - 1 + max_col = block.width() - 1 + if row == 0 or row == max_row or col == 0 or col == max_col: + # the cell is at the edge of block - return the original value + return org_val + vals = [] + for r in (row - 1, row, row + 1): + for c in (col - 1, col, col + 1): + val = block.value(r, c) + if val == nodata_value: + if nodata_mode: + # return nodata if any neighbor has nodata value + return nodata_value + else: + continue + vals.append(val) + if len(vals) == 0: + new_val = nodata_value + else: + new_val = sum(vals) / len(vals) + return new_val diff --git a/tests/src_data/10x5_float64_EPSG27700_org.tif b/tests/src_data/10x5_float64_EPSG27700_org.tif new file mode 100644 index 0000000000000000000000000000000000000000..49a773dee4d6fc0f4dd068ea7b4fe82ddec58178 GIT binary patch literal 855 zcmZwD%TB^T7>41$v?3wIL*ju2iw2BQ;t4?%PZT^LD7s+lhQx(6L=z-&!@{UZcixKO z7Q75sUILfkO#3Qb*ktm2-^@QysMoU+6Ol;~!xTfMxLO7|s&a(vsBDt;Tr`xY4a>j# zqz-07`*URdIrGeXtC>`2rbRZ@%*1O#w4Z9dyd^~6`%>$}GsRZM)!l&?kyVu&DvzFF zImo|`PX0}I$%@_|UXS_eZ1}%mT*L~pdR>1nVhy~sK93E&xTGbj{w_n_Gm_92I+pKp z=Bibz+PPA`Xs_GVuG@1TZ|%!=&waRWckEi%z3Kl=^!4|Jp1+rNg3TCC;S44*g*K*f z0T*!zv$%q5xPdv`#5@*o3yZjoJ6OV9+{1l5z(Xvf6Zni$p`%pkC`WXZV>-$S9p#jc ZQlq1s(NXGjlm;E;oQ`rqM`_ZV&JV)lL_`1p literal 0 HcmV?d00001 diff --git a/tests/src_data/10x5_int16_EPSG27700_org.tif b/tests/src_data/10x5_int16_EPSG27700_org.tif new file mode 100644 index 0000000000000000000000000000000000000000..fe8e3456549f39ef3b8c9881d45bb308a54cdce1 GIT binary patch literal 495 zcmZvZyG{a85Qe|KaB)Qhm5YkHf;aGj2%>0Vq#;;nL2E-|;Toa|l4wDtCfoTchF9=0 zd%|H{f#5A9S!n)5fVZ&#Y7o6a@W_ovDB#@Z! zCw*3RQhv^>aAqQ><+CpS=z9c1^jqxhBZ9j3BlhJQ*rJxyAGkXE!dI$^@flu>@K48q z|KTBhsRF~>fTxNzZ-Nk*BbIhOpKcD^tcr&Ru0@uhtV`oFM?`6*V|tp;-Lx8YyHqPz z?V?@pIz8w4-o9@4oTtZj$8L0;yZ$(%?>(o>s3rj!G9+IxC5xYynlnV@Gh)P9*sMz{IX2j2i*0tuQ(%`pSX&nF_~LIpLHVXPom3JLD%H literal 0 HcmV?d00001