[GIS] Programatically use Openlayers plugin layer in Print Composer

pyqgisqgisqgis-openlayers-plugin

I created some PyQGIS functions to set up a print composer, modify the layout (adding maps, legend, title…) and print the result as an image file. This works fine with one or several vector layers – all of them are shown in the resulting image file.

However, when I try to create a print composer layout that includes a layer from the Openlayers plugin, I do not get the expected result – most of the time the basemap layer is missing at all and sometimes some artifacts show up.

The example code below should be instantly executable after setting the plotdir parameter at the top to a desired output folder. It

  • creates a new QGIS project
  • creates two vector layers in memory, each with one point geometry somewhere
  • loads the OpenStreetMap basemap from the OpenLayers plugin (by clicking through the QGIS Gui menu as I don't know how to load it otherwise)
  • passes the two memory layers and the Openlayers layer to a newly created print composer, sets some layout stuff (legend, title…) and saves the result to an image file.

When I run this, the legend shows all three desired layers, but the canvas is blank (I would expect to see at least the basemap as the point geometries are rather small). Any suggestions how to properly include a basemap into a print composer with PyQGIS? I tried this on Windows 7 with QGIS 2.4 and 2.6.

The code:

# imports
from qgis.core import *
from qgis.utils import *
from PyQt4.QtCore import *
from PyQt4.QtGui import *

plotdir = r'F:\\'

###################################
# function to start a new qgis project
###################################
def startNewProject():
    print('starting new project')
    iface.newProject(thePromptToSaveFlag = False)
    return


###################################
# function to create a memory layer and add it to the map layer registry
###################################
def createMemoryLayer(layerType, layerName, x, y):

    print 'create memory layer'

    myMemoryLayer = QgsVectorLayer(layerType, layerName, "memory")
    QgsMapLayerRegistry.instance().addMapLayer(myMemoryLayer, True)

    feat = QgsFeature()
    feat.setGeometry(QgsGeometry.fromPoint(QgsPoint(x,y)))
    (res, outFeats) = myMemoryLayer.dataProvider().addFeatures( [ feat ] )

    return myMemoryLayer


###################################
# function to load OpenLayers plugin layers and move it to the bottom of the layer tree
###################################
def loadOpenLayersPluginMap(mapProvider, openLayersMap):

    print 'loading openlayers plugin layer'

    webMenu = iface.webMenu() #get object of the Web menu of QGIS

    for webMenuItem in webMenu.actions(): #open the Web menu of QGIS and loop through the list of web plugins
        if 'OpenLayers plugin' in webMenuItem.text(): #look for OpenLayers plugin entry in the Web menu
            openLayersMenu = webMenuItem #and open it

            for openLayersMenuItem in openLayersMenu.menu().actions(): #open the OpenLayers plugin menu entry and loop through the list of map providers
                if mapProvider in openLayersMenuItem.text(): #if the desired mapProvider is found
                    mapProviderMenu = openLayersMenuItem #open its menu entry

                    for map in mapProviderMenu.menu().actions(): #loop through the list of maps for the opened provider
                        if openLayersMap in map.text(): #if the desired map entry is found
                            map.trigger() #click the entry to load the map as a layer

    # move the layer to the bottom of the TOC by putting all other layers above it (openlayers layer cannot be moved directly)
    root = QgsProject.instance().layerTreeRoot()
    for child in root.children():
        if isinstance(child, QgsLayerTreeLayer):
            childClone = child.clone()
            root.insertChildNode(0, childClone)
            root.removeChildNode(child)

    return


###################################
# function to zoom to the extent of a layer
###################################
def zoomToLayerExtent(layer):

    print 'zooming to layer '

    # set the desired layer as active and zoom to it
    iface.setActiveLayer(layer)
    iface.zoomToActiveLayer()

    return


###################################
# function to save a map layout as image file with map composer
###################################
def saveImagesWithPrintComposer(layers, extentLayer, plottitle, plotdir):

    print 'saving images with map composer'

    #######
    # set up layer set, extent and create a map renderer
    #######
    mapRenderer = QgsMapSettings() # new in QGIS 2.4 - replaces QgsMapRenderer()
    layerSet = [layer.id() for layer in layers] # create a list of all layer IDs that shall be part of the composition
    mapRenderer.setLayers(layerSet) # when using QgsMapRenderer, replace this with setLayerSet(layerset)
    mapRectangle = extentLayer.extent() # set extent to the extent of the desired layer
    mapRectangle.scale(2) # set scale as desired
    mapRenderer.setExtent(mapRectangle)
    mapRenderer.setOutputSize(QSize(1600,1200)) #when using QgsMapRenderer(), setOutputSize needs a second argument '[int] dpi'

    #######
    # create a composition and pass the renderer
    #######
    composition = QgsComposition(mapRenderer)
    composition.setPlotStyle(QgsComposition.Print)
    dpi = composition.printResolution()
    dpmm = dpi / 25.4
    width = int(dpmm * composition.paperWidth())
    height = int(dpmm * composition.paperHeight())

    #######
    # add map to the composition
    #######
    x, y = 0, 0
    w, h = composition.paperWidth(), composition.paperHeight()
    composerMap = QgsComposerMap(composition, x, y, w, h)
    composition.addItem(composerMap)

    #######
    # create title label
    #######
    composerLabel = QgsComposerLabel(composition)
    composerLabel.setText(plottitle)
    composerLabel.setFont(QFont("Arial",8))
    composerLabel.adjustSizeToText()
    composerLabel.setBackgroundEnabled(False)
    composition.addItem(composerLabel)
    composerLabel.setItemPosition(20,10)

    #######
    # create legend
    #######
    # create composer legend object
    composerLegend = QgsComposerLegend(composition)

    # set legend layers set (as the same as the map layerset)
    composerLegend.model().setLayerSet(mapRenderer.layers()) #when using QgsMapRenderer, use layerSet() instead of layers()

    # set titles
    composerLegend.setTitle('')
    newFont = QFont("Arial", 8)
    composerLegend.setStyleFont(QgsComposerLegendStyle.Title, newFont)
    composerLegend.setStyleFont(QgsComposerLegendStyle.Subgroup, newFont)
    composerLegend.setStyleFont(QgsComposerLegendStyle.SymbolLabel, newFont)

    # refresh legend
    composerLegend.updateLegend()

    # set feature count activated for all layers of the current map composition
    composerLayerItem = QgsComposerLayerItem()

    def activateFeatureCount(layer):
        composerLayerItem.setLayerID(layer.id())
        composerLayerItem.setShowFeatureCount(True)
        return

    [activateFeatureCount(layer) for layer in layers]

    # set legend background
    composerLegend.setBackgroundEnabled(False)

    # set legend position
    composerLegend.setItemPosition(20,20)

    # add legend to composition
    composition.addItem(composerLegend)

    #######
    # create image and initialize
    #######
    image = QImage(QSize(width, height), QImage.Format_ARGB32)
    image.setDotsPerMeterX(dpmm * 1000)
    image.setDotsPerMeterY(dpmm * 1000)
    image.fill(0)

    #######
    # Render composition
    #######
    painter = QPainter(image)
    sourceArea = QRectF(0, 0, composition.paperWidth(), composition.paperHeight())
    targetArea = QRectF(0, 0, width, height)
    composition.render(painter, targetArea, sourceArea)
    painter.end()

    #######
    # Save composition to image file (other extensions possible)
    #######
    image.save(plotdir + plottitle + '.png', 'png')

    return


###################################
# main function
###################################
def main():

    # start a new qgis project
    startNewProject()

    # create memory layers
    memoryLayer1 = createMemoryLayer('Linestring', 'MyFirstLayer', 2, 47) # some random coordinates
    memoryLayer2 = createMemoryLayer('Linestring', 'MySecondLayer', 9, 48) # some random coordinates

    # add openlayers plugin layer
    loadOpenLayersPluginMap('OpenStreetMap', 'OpenStreetMap')

    # zoom to first layer
    zoomToLayerExtent(memoryLayer1)

    # fetch all layers from the canvas
    allLayers = QgsMapLayerRegistry.instance().mapLayers().values()

    # save to file with print composer
    saveImagesWithPrintComposer(allLayers, memoryLayer1, 'MyPlot', plotdir)

    return

main()
print 'finished'

Best Answer

As directly using a Openlayers plugin layer in a programatically created print composition is not possible (see the accepted answer), this approach can at least be used to create a stationary image file (jpg/png) from a desired Openlayers map extent. Maybe this is helpful to somebody somehow...

It is based on the basemap2Image script which I modified slightly to make it callable from within a PyQGIS script running in the Python console. To use it, you need the following helper functions.

Getting the edge coordinates of a vector layer extent:

###################################
# function to get coordinates of a layer extent
###################################
def getCoordinatesOfLayerExtent(layer):

    print 'getting coordinates of layer extent'
    layerRectangle = layer.extent()
    coordinates = [layerRectangle.xMinimum(), layerRectangle.yMinimum(), layerRectangle.xMaximum(), layerRectangle.yMaximum()]

    return coordinates

Call it by providing a QgsVectorLayer object, e.g.

vectorLayerCoordinates = getCoordinatesOfLayerExtent(myFavoriteVectorLayer)

Coordinates have to be passed to the script in EPSG:3857, so if your vector layer does not use that CRS by default, use the following function to transform the coordinates first:

###################################
# function to transform a set of coordinates from one CRS to another
###################################
def transformCoordinates(coordinates, fromCRS, toCRS):

    print 'transforming coordinates between crs'

    crsSrc = QgsCoordinateReferenceSystem(fromCRS)
    crsDest = QgsCoordinateReferenceSystem(toCRS)
    xform = QgsCoordinateTransform(crsSrc, crsDest)

    # convert list of coordinates to QgsPoint objects
    coordinatesAsPoints = [QgsPoint(coordinates[0], coordinates[1]), QgsPoint(coordinates[2], coordinates[3])]

    # do transformation for each point
    transformedCoordinatesAsPoints = [xform.transform(point) for point in coordinatesAsPoints]

    # transform the QgsPoint objects back to a list of coordinates
    transformedCoordinates = [transformedCoordinatesAsPoints[0].x(), transformedCoordinatesAsPoints[0].y(), transformedCoordinatesAsPoints[1].x(), transformedCoordinatesAsPoints[1].y()]

    return transformedCoordinates

Call it by providing your list of coordinates, the origin CRS and the desired destination CRS, e.g.:

transformedCoordinates = transformCoordinates(coordinates, 4326, 3857)

Now create an empty file called __init__.py next to your script (this is necessary for importing functions from other scripts within the same folder) and another one, looking like this (this is the modified basemap2Image script):

#!/usr/bin/env python
# Nov 30, 2012
# Angel Joyce Torres Ramirez
# joys.tower@gmail.com
# I am not responsible for any use you give to this program, I did self-study and study purposes
# License:
# BaseMap2Image 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 sys
import signal
from qgis.core import *
from qgis.utils import *
from PyQt4.QtCore import *
from PyQt4.QtGui import *
from PyQt4.QtWebKit import *
from functools import partial

def main(htmlDirectory, arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8):

    print arg1, arg2, arg3, arg4, arg5, arg6, arg7, arg8

    def onLoadFinished(result):

        global repaintEnd
        global xMin
        global yMin
        global xMax
        global yMax
        global width
        global height
        global fileOut
        global fileFormat

        if not result:
            print "Request failed"
            web.close()

        action = "map.zoomToExtent(new OpenLayers.Bounds("+xMin+", "+yMin+", "+xMax+", "+yMax+"), true);"
        web.page().mainFrame().evaluateJavaScript(action)

        repaintEnd = None
        pauseReq()

        img = QImage(web.page().mainFrame().contentsSize(), QImage.Format_ARGB32_Premultiplied)
        imgPainter = QPainter(img)
        web.page().mainFrame().render(imgPainter)
        imgPainter.end()
        img = img.scaled(width, height, Qt.KeepAspectRatio, Qt.SmoothTransformation )
        img.save( fileOut, fileFormat)
        print 'saving basemap'
        web.close()

    def pauseReq():
        global repaintEnd
        timerMax.start()
        while not repaintEnd:
            qApp.processEvents()
        timerMax.stop()


    def endTimer():
        global repaintEnd
        repaintEnd = True

    def resolutions():
        if olResolutions == None:
          resVariant = web.page().mainFrame().evaluateJavaScript("map.layers[0].resolutions")
          olResolutions = []
          for res in resVariant.toList():
            olResolutions.append(res.toDouble()[0])
        return olResolutions

    global layer
    layer = arg1
    global xMin
    xMin = arg2
    global yMin
    yMin = arg3
    global xMax
    xMax = arg4
    global yMax
    yMax = arg5

    global fileOut
    fileOut = arg6

    global fileFormat
    fileFormat="png"
    if fileOut.find(".") != -1:
        fileFormat = fileOut.split('.')[1]
    if fileFormat == "jpg":
        fileFormat = "jpeg"

    timeWait = 2000
    global width
    width = float(arg7)
    global height
    height= float(arg8)

    web = QWebView()
    timerMax = QTimer()

    global repaintEnd
    repaintEnd = None
    olResolutions = None

    timerMax.setSingleShot(True)
    timerMax.setInterval(int(timeWait))
    QObject.connect(timerMax, SIGNAL("timeout()"), endTimer)

    web.setFixedSize(width,height)
    pathUrl = "file:///%s/%s.html" % (htmlDirectory, layer)

    web.connect(web, SIGNAL("loadFinished(bool)"), onLoadFinished)

    web.load(QUrl(pathUrl))
    web.show()

To call the main function of this script, you have to pass the following arguments:

  1. htmlDirectory: path to the html directory that was part of the github package (clone the repository or download as zip, then copy the html directory to a convenient location)

  2. mapProvider: choose one from the html directory to which a corresponding .html file exists, e.g. 'google_streets' or 'osm'

  3. xMin: coordinates of layer extent in EPSG:3857

  4. yMin: coordinates of layer extent in EPSG:3857

  5. xMax: coordinates of layer extent in EPSG:3857

  6. yMax: coordinates of layer extent in EPSG:3857

  7. filename: path and filename of the output image file

  8. width: width of the output image file

  9. height: height of the output image file

Make sure to pass all arguments as strings since the script was originally developed to be used from the command line where no other types exist. An examplary call could look something like this:

basemap2Image.main(r'c:\PyQGIS\html', 'osm', str(transformedCoordinates[0]), str(transformedCoordinates[1]), str(transformedCoordinates[2]), str(transformedCoordinates[3]), r'c:\PyQGIS\basemap.png', '1600', '1200')

Last but not least, in order to run other Python scripts from a running Python console script, you have to tell the console where to look for it. The easiest and most permanent way for me to do this was searching for the file console.py in my QGIS folder and add the line sys.path.append('/path/to/basemap2Image script') under the import section (restart your QGIS to make it aware of the change).

A lot of work for such a simple feature... however, when running this as described above, a window should pop up, showing you a worldmap of your desired Openlayers provider, then zooming to the given extent, save the content to the given filename and then close the window again. This image file could then be used as a basemap or whatever else in a print composition.