1

I am currently struggling with the zoomToSelected(...) function in my QGIS Python processing script.

Users should select a city district via the GUI and then the tool should zoom to this feature and export the map canvas as an image.

Unfortunately, map canvas freezes purely randomly. Changes in the TOC are no longer adopted.

I am using this code section to zoom to the feature and to create the image:

[...] 
# Set the districts layer as active layer in the TOC
iface.setActiveLayer(districts)
 
## Create a picture from the district
# Select the district and zoom to the selection
districts.select(district_id)
iface.mapCanvas().zoomToSelected(districts)
iface.mapCanvas().refresh()
time.sleep(15)
 
# Create output path for the image in project directory
image_path = os.path.join(QgsProject.instance().homePath(), "temp_image.png")
iface.mapCanvas().saveAsImage(image_path)
 
# Clear selection
districts.deselect(district_id)
[...] 

This code section is used in an function that is written by myself and that is called in def processAlgorithm(...).

I have already found this post, which also describes the behavior very well: PyQGIS zoomscale freezes map canvas. Here the solution is to add those lines of code to the script:

def flags(self):
 return QgsProcessingAlgorithm.FlagNoThreading

After adding the lines of code, everything runs smoothly (map canvas no longer freezes) but a second window now opens when the tool is executed and closes again when the tool is finished.

enter image description here

The main window now shows a process progress of 0%, before adding the lines of code it was always 100%. Nevertheless a result is written at the end, so the script finished successfully.

enter image description here

At the end the map also zoomed to the correct feature, but you only ever get the map image exported before zooming. However, the order in the script is correct. First zoom, then export. It seems as if the script pulls and uses the current map canvas right at start instead of exporting after zooming.

So the question is, am I still doing something wrong? I have already built a delay into the script so that there are 15 seconds between zoom and export.

Any hint on how to improve this would be great! If necessary I can also provide the rest of the code, but currently I don ́t think its needed.

asked May 26, 2024 at 16:21

1 Answer 1

3

Don't use time.sleep(). If you want to zoom to a feature then save the canvas as an image, use QTimer.singleShot().

However, I recommend using one of the QgsMapRendererJob subclasses as per the example below.

This example also uses a custom widget wrapper to allow the user to select the district to export which should solve the problem in your other question.

from processing.gui.wrappers import WidgetWrapper
from qgis.PyQt.QtCore import (QCoreApplication,
 QVariant,
 Qt)
 
from qgis.PyQt.QtWidgets import (QLabel,
 QWidget,
 QGridLayout,
 QComboBox)
 
from qgis.core import (QgsProcessing,
 QgsProcessingAlgorithm,
 QgsProcessingParameterMatrix,
 QgsProcessingParameterFileDestination,
 QgsMapLayerProxyModel,
 QgsFieldProxyModel,
 QgsMapRendererParallelJob)
 
from qgis.gui import (QgsMapLayerComboBox,
 QgsFieldComboBox)
 
from qgis.utils import iface
import os
 
class AddLayoutTable(QgsProcessingAlgorithm):
 INPUT_PARAMS = 'INPUT_PARAMS'
 OUTPUT_PATH = 'OUTPUT_PATH'
 render = None
 
 def __init__(self):
 super().__init__()
 
 def name(self):
 return "exportdistrict"
 
 def displayName(self):
 return "Export District"
 
 def group(self):
 return "General"
 
 def groupId(self):
 return "general"
 
 def shortHelpString(self):
 return "Export map of selected district."
 
 def helpUrl(self):
 return "https://qgis.org"
 
 def createInstance(self):
 return type(self)()
 
 def flags(self):
 return QgsProcessingAlgorithm.FlagNoThreading
 
 def initAlgorithm(self, config=None):
 input_params = QgsProcessingParameterMatrix(self.INPUT_PARAMS, 'Input Parameters')
 input_params.setMetadata({'widget_wrapper': {'class': CustomParametersWidget}})
 self.addParameter(input_params)
 self.addParameter(QgsProcessingParameterFileDestination(self.OUTPUT_PATH,
 'Save image to',
 fileFilter='PNG files (*.png)'))
 
 def processAlgorithm(self, parameters, context, feedback):
 # Retrieve the list of parameters returned by the custom widget wrapper
 input_params_list = self.parameterAsMatrix(parameters, 'INPUT_PARAMS', context)
 # Access the list items to retrieve each parameter object
 district_lyr = input_params_list[0]
 district_fld = input_params_list[1]
 district_name = input_params_list[2]
 district_feats = [ft for ft in district_lyr.getFeatures() if ft[district_fld] == district_name]
 if not district_feats:
 return {}
 district_feat = district_feats[0]
 
 save_path = self.parameterAsFileOutput(parameters, self.OUTPUT_PATH, context)
 
 canvas = iface.mapCanvas()
 settings = canvas.mapSettings()
 settings.setDestinationCrs(district_lyr.crs())
 extent = district_feat.geometry().boundingBox()
 extent.grow(0.01)
 settings.setExtent(extent)
 self.render = QgsMapRendererParallelJob(settings)
 self.render.finished.connect(lambda: self.renderFinished(save_path))
 # Start the rendering
 self.render.start()
 self.render.waitForFinished()
 return {'Exported Image': save_path}
 
 def renderFinished(self, save_path):
 img = self.render.renderedImage()
 img.save(save_path, "png")
# Widget Wrapper class
class CustomParametersWidget(WidgetWrapper):
 def createWidget(self):
 self.cpw = CustomWidget()
 return self.cpw
 
 def value(self):
 # This method gets the parameter values and returns them in a list...
 # which will be retrieved and parsed in the processAlgorithm() method
 self.lyr = self.cpw.getLayer()
 self.fld = self.cpw.getField()
 self.district = self.cpw.getDistrict()
 return [self.lyr, self.fld, self.district]
 
# Custom Widget class
class CustomWidget(QWidget):
 
 def __init__(self):
 super(CustomWidget, self).__init__()
 self.lyr_lbl = QLabel('Select District Layer', self)
 self.lyr_cb = QgsMapLayerComboBox(self)
 self.fld_lbl = QLabel('Select District Field')
 self.fld_cb = QgsFieldComboBox(self)
 self.id_lbl = QLabel('Select District')
 self.id_cb = QComboBox(self)
 self.layout = QGridLayout(self)
 self.layout.addWidget(self.lyr_lbl, 0, 0, 1, 1)
 self.layout.addWidget(self.lyr_cb, 0, 1, 1, 2)
 self.layout.addWidget(self.fld_lbl, 1, 0, 1, 1)
 self.layout.addWidget(self.fld_cb, 1, 1, 1, 2)
 self.layout.addWidget(self.id_lbl, 2, 0, 1, 1)
 self.layout.addWidget(self.id_cb, 2, 1, 1, 2)
 # Set filter on the map layer combobox (here we show only polygon layers)
 self.lyr_cb.setFilters(QgsMapLayerProxyModel.PolygonLayer)
 self.fld_cb.setLayer(self.lyr_cb.currentLayer())
 # Set filters on field combobox (here we show only string fields)
 self.fld_cb.setFilters(QgsFieldProxyModel.String)
 self.lyr_cb.layerChanged.connect(self.layerChanged)
 self.populateDistricts()
 self.fld_cb.fieldChanged.connect(self.populateDistricts)
 
 def layerChanged(self):
 self.fld_cb.setLayer(self.lyr_cb.currentLayer())
 
 def populateDistricts(self):
 self.id_cb.clear()
 district_lyr = self.lyr_cb.currentLayer()
 if not district_lyr:
 return
 id_fld = self.fld_cb.currentField()
 fld_idx = district_lyr.fields().lookupField(id_fld)
 id_vals = district_lyr.uniqueValues(fld_idx)
 self.id_cb.addItems([str(val) for val in id_vals])
 
 def getLayer(self):
 return self.lyr_cb.currentLayer()
 
 def getField(self):
 return self.fld_cb.currentField()
 
 def getDistrict(self):
 return self.id_cb.currentText()

The resulting algorithm dialog looks like below, where user can select the layer and the field containing the district names. A third combobox is then populated with the district names found in the layer. These parameters are not dependant on any hard-coded project layer or file path.

enter image description here

I have returned the No Threading flag in this example, but you may get away without it, since we are not actually modifying/zooming the canvas. Do some testing and see if you get any crashes or weird behaviour.

answered May 27, 2024 at 11:07

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.