Draw a Custom Map Canvas Item in PyQGIS

Some graphics belong on the map canvas but not in any layer: a crosshair showing a GPS position, a range ring around a selected site, a compass rose that follows the view, a measurement readout next to the cursor. They are temporary, interactive and specific to a tool. QGIS draws them as canvas items — Qt graphics items layered over the rendered map, positioned in map coordinates and repainted instantly without re-rendering any layer.

This recipe belongs to Extending QGIS with Custom Classes. It starts with the built-in canvas items, then subclasses QgsMapCanvasItem to draw a range ring with distance labels, keeps it positioned through pans and zooms, and removes it cleanly.

Canvas items above the rendered mapThe map canvas shows a rendered map image produced by the layer renderers. Above it, canvas items are Qt graphics items in the same scene: vertex markers, rubber bands, annotations and custom items. They are positioned from map coordinates, drawn in pixels, and repainted without re-rendering layers, which makes them suitable for fast, temporary graphics.Graphics above the map, not in itrendered map image (layers)canvas items: markers · rubber bands · annotationsyour QgsMapCanvasItem subclassitems repaint instantly; layers re-render only when needed

Prerequisites

  • QGIS 3.34 LTR or newer, or the QGIS 4 series, with the code running inside QGIS Desktop (the canvas exists only there).
  • Access to iface.mapCanvas(), from the Python console or a plugin.

Start with the built-in items

Before writing a class, check whether a built-in item already does the job. Three cover most needs.

from qgis.core import QgsPointXY, QgsGeometry, QgsWkbTypes
from qgis.gui import QgsVertexMarker, QgsRubberBand
from qgis.PyQt.QtGui import QColor
from qgis.utils import iface

canvas = iface.mapCanvas()

marker = QgsVertexMarker(canvas)
marker.setCenter(QgsPointXY(571244, 5934019))
marker.setIconType(QgsVertexMarker.ICON_CROSS)
marker.setIconSize(16)
marker.setPenWidth(2)
marker.setColor(QColor("#b91c1c"))

band = QgsRubberBand(canvas, QgsWkbTypes.PolygonGeometry)
band.setToGeometry(QgsGeometry.fromPointXY(QgsPointXY(571244, 5934019)).buffer(250, 36), None)
band.setColor(QColor(37, 99, 235, 60))
band.setStrokeColor(QColor("#2563eb"))
band.setWidth(2)

Breakdown: QgsVertexMarker draws a fixed-size icon at a map point — a cross, box, circle or X — and is the quickest way to mark a location. QgsRubberBand draws any geometry with fill and outline, in map units, so a 250 m buffer stays 250 m as you zoom; it is the workhorse of map tools, as in highlighting a feature with a rubber band. Passing None as the layer argument means the geometry is already in the canvas CRS. For text and HTML notes that users can move and save with the project, annotations are the built-in route.

Subclass QgsMapCanvasItem

When none of those fits — mixed graphics, text that must stay a fixed pixel size next to a map-unit shape, custom interaction — subclass QgsMapCanvasItem. It needs paint, boundingRect and a way to update its position when the view changes.

The methods a canvas item implementsupdatePosition recomputes the item's screen position and size from its map coordinates whenever the canvas extent changes. boundingRect returns the area the item paints in, in item coordinates, so Qt knows what to redraw. paint draws with the QPainter in pixels. setCenter or similar methods store map coordinates and trigger an update.Map coordinates in, pixels outmap pointstored by youupdatePosition()toCanvasCoordinatesboundingRect()redraw areapaint()QPainterthe canvas calls updatePosition after every pan and zoom

from qgis.gui import QgsMapCanvasItem
from qgis.PyQt.QtCore import QRectF, QPointF, Qt
from qgis.PyQt.QtGui import QPen, QFont

class RangeRingItem(QgsMapCanvasItem):
    def __init__(self, canvas, center, radii_m=(250, 500, 1000)):
        super().__init__(canvas)
        self.canvas = canvas
        self.center = center              # QgsPointXY in canvas CRS
        self.radii = radii_m
        self._px = []
        self.setZValue(50)
        self.updatePosition()

    def setCenter(self, center):
        self.center = center
        self.updatePosition()

    def updatePosition(self):
        mupp = self.canvas.mapUnitsPerPixel()
        self._px = [r / mupp for r in self.radii]
        pos = self.toCanvasCoordinates(self.center)
        self.prepareGeometryChange()
        self.setPos(pos)
        self.update()

    def boundingRect(self):
        r = (max(self._px) if self._px else 0) + 40
        return QRectF(-r, -r, 2 * r, 2 * r)

    def paint(self, painter, option=None, widget=None):
        painter.setRenderHint(painter.Antialiasing)
        painter.setPen(QPen(QColor("#2563eb"), 1.5, Qt.DashLine))
        painter.setFont(QFont("Sans", 8))
        for r_m, r_px in zip(self.radii, self._px):
            painter.drawEllipse(QPointF(0, 0), r_px, r_px)
            painter.drawText(QPointF(4, -r_px - 3), f"{r_m:,} m")

Breakdown: The item stores its anchor in map coordinates and converts to screen coordinates in updatePosition, which the canvas calls whenever the extent changes. toCanvasCoordinates gives the pixel position of the map point; the item is placed there, so paint can draw around (0, 0). Radii are converted from metres to pixels using the canvas's map units per pixel, which assumes a projected canvas CRS in metres. prepareGeometryChange() must be called before the bounding rectangle changes, or Qt leaves stale pixels behind. Labels are drawn in a fixed font size, so they stay readable at every zoom while the rings scale with the map.

Show, move and remove the item

Canvas items live as long as something holds them and the canvas scene owns them. Removing them explicitly is part of every tool's cleanup.

rings = RangeRingItem(canvas, QgsPointXY(571244, 5934019), radii_m=(500, 1000, 2000))

# follow a moving position, e.g. from a GPS feed or a map click
rings.setCenter(QgsPointXY(571600, 5934400))

# remove when the tool is deactivated or the plugin unloads
canvas.scene().removeItem(rings)
del rings
canvas.refresh()

Breakdown: Creating the item with the canvas adds it to the canvas's graphics scene; it appears immediately. setCenter updates the anchor and repositions without touching any layer, which is why canvas items suit live positions. Removing the item from the scene before dropping the Python reference avoids crashes from a C++ object being deleted while still in the scene. In a plugin, track every item you create and remove them all in unload() and when the relevant map tool is deactivated.

Keep it correct when the CRS changes

The example assumes the canvas CRS is projected in metres. When users switch the project to a geographic CRS, metres per pixel no longer means anything. Measuring ellipsoidally and transforming keeps the rings correct.

Distances in any canvas CRSThe ring radius in metres is converted to canvas units by measuring with QgsDistanceArea from the centre to a point the required distance away along a bearing, in the canvas CRS. In a projected CRS this matches map units per pixel; in a geographic CRS it gives the correct, latitude-dependent size. The item listens to destinationCrsChanged to recompute.Metres, whatever the canvas CRSprojected CRSmap units = metresmapUnitsPerPixelgeographic CRSdegrees ≠ metresmeasure on ellipsoidlistendestinationCrsChangedrecompute radii

from qgis.core import QgsDistanceArea, QgsProject

class GeoRangeRingItem(RangeRingItem):
    def __init__(self, canvas, center, radii_m=(250, 500, 1000)):
        self.da = QgsDistanceArea()
        super().__init__(canvas, center, radii_m)
        canvas.destinationCrsChanged.connect(self.updatePosition)

    def updatePosition(self):
        crs = self.canvas.mapSettings().destinationCrs()
        self.da.setSourceCrs(crs, QgsProject.instance().transformContext())
        self.da.setEllipsoid(QgsProject.instance().ellipsoid() or "EPSG:7030")
        mupp = self.canvas.mapUnitsPerPixel()
        self._px = []
        for r in self.radii:
            east = self.da.computeSpheroidProject(self.center, r, 1.5708) \
                if crs.isGeographic() else QgsPointXY(self.center.x() + r, self.center.y())
            self._px.append(abs(east.x() - self.center.x()) / mupp)
        self.prepareGeometryChange()
        self.setPos(self.toCanvasCoordinates(self.center))
        self.update()

Breakdown: In a geographic CRS, computeSpheroidProject finds the point a given distance east of the centre on the ellipsoid, and the difference in longitude, divided by degrees per pixel, gives the radius in pixels at that latitude. In a projected CRS the original arithmetic is used. Connecting to destinationCrsChanged recomputes when the user changes the project CRS. Rings drawn this way are circles on screen; on large areas in a geographic CRS, true geodesic circles would be ellipses — use a rubber band with a geodesic buffer if that accuracy matters.

Draw many marks in one item

A separate canvas item per point is fine for a handful; for a few hundred live positions — vehicles, sensors, search results — one item that draws them all is far lighter. The pattern is the same: store map coordinates, convert in updatePosition, paint in pixels.

class PositionsItem(QgsMapCanvasItem):
    def __init__(self, canvas):
        super().__init__(canvas)
        self.canvas = canvas
        self.points = []            # QgsPointXY list in canvas CRS
        self._screen = []
        self.setZValue(60)

    def setPoints(self, points):
        self.points = list(points)
        self.updatePosition()

    def updatePosition(self):
        self.prepareGeometryChange()
        self.setPos(0, 0)
        self._screen = [self.toCanvasCoordinates(p) for p in self.points]
        self.update()

    def boundingRect(self):
        return QRectF(0, 0, self.canvas.width(), self.canvas.height())

    def paint(self, painter, option=None, widget=None):
        painter.setPen(QPen(QColor("#17211d"), 1))
        painter.setBrush(QColor("#b45309"))
        for p in self._screen:
            painter.drawEllipse(p, 4, 4)

Breakdown: Placing the item at the scene origin and covering the whole canvas lets paint draw at absolute screen positions computed in updatePosition. Replacing the point list and calling setPoints from a timer or a network callback animates hundreds of positions with a single repaint. Beyond a few thousand points, or when positions must be queried, styled by attribute or saved, a memory layer with a fast refresh is the better tool.

Interact with the item

Canvas items can respond to the mouse, but in QGIS, interaction normally belongs to a map tool. Let the tool receive clicks and drags and update the item.

from qgis.gui import QgsMapToolEmitPoint

class PlaceRingsTool(QgsMapToolEmitPoint):
    def __init__(self, canvas):
        super().__init__(canvas)
        self.item = None
        self.canvasClicked.connect(self.on_click)

    def on_click(self, point, button):
        if self.item is None:
            self.item = GeoRangeRingItem(self.canvas(), point, (500, 1000, 2000))
        else:
            self.item.setCenter(point)

    def deactivate(self):
        if self.item is not None:
            self.canvas().scene().removeItem(self.item)
            self.item = None
        super().deactivate()

tool = PlaceRingsTool(canvas)
canvas.setMapTool(tool)

Breakdown: QgsMapToolEmitPoint emits the clicked map point already in canvas coordinates, so the tool only has to create or move the item. Removing the item in deactivate means switching to another tool cleans up automatically. This split — the tool handles input, the item handles drawing — matches how QGIS's own tools are built and keeps both simple; creating a custom map tool covers the tool side.

QGIS version compatibility

QgsMapCanvasItem, QgsVertexMarker and QgsRubberBand work on QGIS 3.34 LTR, 3.40 LTR and QGIS 4. On QGIS 4, Qt enums are scoped: Qt.PenStyle.DashLine, QPainter.RenderHint.Antialiasing, QgsVertexMarker.IconType.ICON_CROSS; geometry types are Qgis.GeometryType.Polygon. Writing the scoped forms works on Qt5-based 3.x releases too when using qgis.PyQt.

Troubleshooting

  • Trails of old drawings remain. prepareGeometryChange() was not called before the bounding rectangle changed, or boundingRect is too small for what paint draws.
  • The item does not move when panning. updatePosition is not converting from map coordinates.
  • QGIS crashes on plugin reload. Items were not removed from the scene in unload().
  • Rings have the wrong size. The canvas CRS is geographic; measure on the ellipsoid.

Conclusion

Use vertex markers and rubber bands where they suffice; otherwise subclass QgsMapCanvasItem, store map coordinates, convert to pixels in updatePosition, declare the redraw area in boundingRect, paint in pixels, handle CRS changes, let a map tool drive interaction, and remove every item from the scene when finished.

Frequently Asked Questions

Do canvas items appear in print layouts? No. They exist only on the canvas. For anything that must be printed, use a layer, annotation or layout item.

Are canvas items saved in the project? No. Annotations are, which is the difference that matters for users.

Can I draw on the canvas from a background thread? No. Create and update items on the main thread, for example via a signal from a task.

How many items can the canvas handle? Hundreds comfortably; for thousands, draw them in one item or use a memory layer instead.