Building a Custom QgsMapCanvasItem Overlay

Write a QgsMapCanvasItem that follows the map: storing map coordinates and converting in paint, a bounding rectangle that prevents stale pixels,…

TL;DR: store geometry in map coordinates, convert to screen coordinates inside paint(), and return a boundingRect() in canvas pixels that generously covers everything you draw — the overlay then follows pan and zoom correctly and leaves no stale pixels behind. This page is part of the custom map canvas overlays and rendering guide.

Complete Runnable Code

python
"""A canvas overlay that labels a set of map positions."""
from qgis.core import QgsPointXY
from qgis.gui import QgsMapCanvas, QgsMapCanvasItem
from qgis.PyQt.QtCore import QPointF, QRectF, Qt
from qgis.PyQt.QtGui import QColor, QFont, QFontMetricsF, QPainter, QPen


class LabelledPointsItem(QgsMapCanvasItem):
    """Draws a dot and a caption at each map position.

    Positions are held in map coordinates and converted at paint time, so the
    overlay stays put when the user pans or zooms.
    """

    PADDING = 12          # pixels of slack added to the bounding rectangle

    def __init__(self, canvas: QgsMapCanvas):
        super().__init__(canvas)
        self._canvas = canvas
        self._points: list[tuple[QgsPointXY, str]] = []
        self._font = QFont()
        self._font.setPointSizeF(9.0)

    def set_points(self, points: list[tuple[QgsPointXY, str]]) -> None:
        """Replace the drawn set. Map coordinates in, repaint scheduled."""
        self._points = list(points)
        self.updatePosition()
        self.update()

    def paint(self, painter: QPainter, option=None, widget=None) -> None:
        """Called by the scene. Convert here, never store screen coordinates."""
        if not self._points:
            return
        painter.setRenderHint(QPainter.Antialiasing, True)
        painter.setFont(self._font)
        painter.setPen(QPen(QColor(31, 97, 63), 1.5))

        for map_point, label in self._points:
            pos = self.toCanvasCoordinates(map_point)
            painter.drawEllipse(QPointF(pos), 4.0, 4.0)
            painter.drawText(QPointF(pos.x() + 8.0, pos.y() - 6.0), label)

    def boundingRect(self) -> QRectF:
        """Canvas-pixel bounds of everything paint() will touch, plus slack."""
        if not self._points:
            return QRectF()
        metrics = QFontMetricsF(self._font)
        rect = QRectF()
        for map_point, label in self._points:
            pos = self.toCanvasCoordinates(map_point)
            width = metrics.horizontalAdvance(label) + 16.0
            rect = rect.united(QRectF(pos.x() - self.PADDING, pos.y() - self.PADDING,
                                      width + self.PADDING, metrics.height() + self.PADDING))
        return rect

    def updatePosition(self) -> None:
        """Called when the canvas extent changes — recompute cached geometry."""
        self.prepareGeometryChange()
What happens between an extent change and a repaint A four-step repaint path: the canvas extent changes, the item recomputes its bounding rectangle, Qt decides the item needs redrawing, and paint converts stored map coordinates to screen pixels. extent changes pan or zoom updatePosition() recompute bounds Qt invalidates uses boundingRect paint() convert and draw notify bounds draw

Architecture Breakdown

paint() receives a QPainter, not a render context

Unlike a layer renderer, a canvas item paints with a plain Qt QPainter in canvas pixel coordinates. There is no QgsRenderContext, no map units, and no automatic scaling — which makes the method simple and makes the coordinate conversion entirely your responsibility.

toCanvasCoordinates() is the conversion, inherited from QgsMapCanvasItem, and it delegates to the canvas’s current QgsMapToPixel. Calling it inside paint() means every repaint uses the current view, which is exactly why the overlay follows the map.

boundingRect() is consulted far more often than paint()

Qt uses the bounding rectangle to decide whether the item intersects the region being redrawn. Returning one that is too small means Qt concludes your item cannot be affected and leaves the previous pixels on screen — the classic “smear” bug, where an old label stays visible after a pan.

The four methods you have to get right A grid of the four methods a canvas item overrides — paint, boundingRect, updatePosition and the constructor — showing what each is responsible for and the symptom of getting it wrong. responsible for wrong gives __init__ attaching to the canvas the item never appears paint() the drawing itself nothing, or wrong colours boundingRect() the invalidation region stale pixels left behind updatePosition() recomputing on extent change drift while panning

Too large costs only performance, so the rectangle should include pen width, text metrics, and any glow or shadow. The PADDING constant above is deliberate slack: cheap insurance against an off-by-a-few-pixels calculation.

prepareGeometryChange() before the bounds change

Qt caches the bounding rectangle. Changing what boundingRect() would return without calling prepareGeometryChange() first leaves the scene with a stale cached region, and the symptom is again stale pixels. Calling it in updatePosition() and whenever the drawn set changes covers both cases.

Choosing Between an Item, a Rubber Band and a Layer

The canvas offers three different ways to put marks on the map, and writing a custom item is only right for one of them.

Rubber band, canvas item, or a real layer? A decision tree over three ways to draw on the map: a rubber band for transient geometry, a canvas item for custom drawing that follows the map, and a memory layer when the result should behave like data. What are you drawing, and for how long? transient geometry QgsRubberBand no code to write custom drawing QgsMapCanvasItem you control paint() it is data a memory layer styled, queryable, exportable

QgsRubberBand already does transient geometry — highlights, previews, measurement lines — with no code beyond feeding it points, and it handles conversion and repaint for you. Reach for a custom item when you need drawing a rubber band cannot express: text anchored to positions, custom symbology, per-vertex handles.

The third option is worth considering more often than it is. If what you are drawing is really data — results a user might want to style, query or export — a memory layer gives you all of that for free, and it participates in the map properly rather than floating above it.

Attaching and Removing It

python
class MyPlugin:
    def initGui(self) -> None:
        self.overlay = LabelledPointsItem(self.iface.mapCanvas())

    def unload(self) -> None:
        canvas = self.iface.mapCanvas()
        if self.overlay is not None:
            canvas.scene().removeItem(self.overlay)     # detach before dropping
            self.overlay = None
        canvas.refresh()

Constructing the item with the canvas attaches it to the scene automatically; removing it is not automatic and must be done explicitly. An item left in the scene after unload keeps painting, and because the plugin that owns it is gone, there is no way for the user to remove it short of restarting QGIS.

Keeping the Overlay Cheap

An overlay is repainted far more often than most people expect — on every pan step, every zoom frame, every window resize, and whenever another item in the scene changes. That makes paint() one of the few places in a plugin where a few milliseconds are genuinely worth arguing about.

Two habits keep it manageable. Do no work in paint() that could have been done when the data changed: formatting strings, computing colours from attributes and sorting can all happen once in the setter. And bound what you draw by what is visible — an overlay holding ten thousand positions should skip the ones outside the current extent rather than converting every one of them and letting Qt clip.

If an overlay still costs more than a few milliseconds after both, the honest answer is usually that it should be a layer instead. Layers have a rendering pipeline built for scale, including caching and simplification, which a canvas item has to reimplement badly.

Production Best Practices

  • Store map coordinates, convert in paint(). Storing screen coordinates breaks on the first pan.
  • Over-estimate boundingRect(). Too small leaves artefacts; too large costs a little time.
  • Call prepareGeometryChange() before anything that alters the bounds.
  • Keep paint() cheap. It runs on every canvas repaint, including continuously during a drag.
  • Remove the item from the scene in unload(), then drop the reference.
  • Set an explicit setZValue() when a plugin draws more than one overlay, so the stacking order is intentional.

Frequently Asked Questions

Why does my overlay smear when I pan?

The bounding rectangle is too small, so Qt is not invalidating everywhere you actually drew. Increase the slack, include text metrics and pen width, and make sure prepareGeometryChange() is called when the geometry changes.

A second cause is drawing outside the rectangle you reported — a label offset that pushes text past the bounds you calculated. The rectangle must cover everything paint() touches, not just the anchor points.

Why does the overlay disappear after a while?

Almost always because nothing in Python holds a reference to it. Adding an item to the scene does not create a Python reference, so an item constructed into a local variable is collected at the next garbage collection and vanishes. Keep it on the plugin for as long as it should be visible.

How do I make the overlay respond to clicks?

Canvas items can override mousePressEvent, but in practice a QgsMapTool is the better place for interaction: the tool receives canvas events with map coordinates already resolved, and it can drive the item. Keeping drawing and interaction in separate objects also makes both easier to test.

Does the overlay appear in a map export?

No. Canvas items live in the canvas scene, not in the map rendering pipeline, so a layout export or a QgsMapRendererJob does not include them. If the marks must appear in output, they need to be a layer or drawn into the layout separately.

How do I draw in map units rather than pixels?

Convert two map positions and use the distance between them, rather than assuming a scale. A circle of “50 metres” is drawn by converting the centre and a point 50 metres away and using the pixel distance between them as the radius, which then stays correct through every zoom.

Should I cache the converted coordinates?

For a handful of points, no — the conversion is cheap and caching adds a staleness problem. For thousands of vertices it is worth it, with the cache rebuilt in updatePosition() when the extent changes rather than on every paint. That is the difference between a smooth drag and a stuttering one.