Custom Map Canvas Overlays and Rendering in PyQGIS

Build responsive, production-grade QgsMapCanvasItem overlays in QGIS 3.x: coordinate transformation, paint() contract, bounding box management, event…

Implementing custom canvas overlays is a foundational capability for interactive, production-grade QGIS plugins. Unlike static vector or raster layers, overlays render directly on the viewport, enabling real-time visual feedback, dynamic annotations, selection highlights, and tool-specific graphics without modifying the underlying geospatial data. This topic is part of the Plugin Development & UI Integration guide. For GIS developers and automation engineers, mastering this rendering pipeline is essential when building workflows that demand responsive, context-aware mapping interfaces.

Prerequisites

Before implementing custom overlays, confirm your environment meets these baseline requirements:

  • QGIS 3.28+ (LTR): The QgsMapCanvasItem API and scene-graph invalidation behaviour are stable across modern LTR releases. Earlier versions exhibit inconsistent bounding-rect invalidation.
  • Python 3.9+ with access to qgis.core, qgis.gui, and qgis.PyQt modules. Verify your environment loads the correct SIP bindings for Qt5/Qt6 compatibility.
  • Qt Graphics View fundamentals: Working knowledge of QGraphicsItem, QPainter, and coordinate transformation matrices. Consult the official Qt Graphics View Framework documentation for scene-graph and viewport-update mechanics.
  • Spatial reference awareness: Familiarity with how QGIS handles on-the-fly coordinate transformations and CRS handling and screen-to-map coordinate mapping.

Canvas overlays frequently pair with configuration interfaces. When building parameter-driven overlays, route user inputs through designing Qt dialogs and form widgets before applying them to the rendering pipeline. This architectural separation ensures UI logic never interferes with the canvas refresh cycle, preserving frame-rate stability during interaction. Overlays that spawn background computation should delegate that work to the asynchronous task execution with QgsTask pattern, so paint() stays strictly graphical.

Architecture and Internals

The QGIS map canvas is an extended Qt QGraphicsView augmented with geospatial coordinate handling, layer compositing, and anti-aliasing controls. Every custom overlay is a QGraphicsItem attached to the canvas scene — QGIS extends the bare Qt base with QgsMapCanvasItem, which wires in CRS-aware coordinate conversion and automatic scene registration.

The rendering pipeline follows this deterministic sequence: map coordinates stored in the item are converted to screen pixels on demand via toCanvasCoordinates(), which internally delegates to QgsMapToPixel; the canvas then calls paint() on every registered item during viewport refresh; and boundingRect() defines the minimal dirty rectangle that the graphics scene uses to decide which items need repainting.

QGIS canvas overlay rendering pipeline Five-stage pipeline: map coordinates in CRS flow into QgsMapToPixel, then into QgsMapCanvasItem in the scene, then into paint(QPainter), which is bounded by boundingRect() returning the redraw region. Map coords stored in CRS QgsMapToPixel toCanvasCoordinates() QgsMapCanvasItem in scene graph paint(QPainter) + boundingRect() boundingRect() sets the dirty-rect for minimal repaints

The key insight driving the entire API design is that QgsMapCanvasItem deliberately keeps map coordinates and screen coordinates separate. You store geometry in the item’s CRS coordinate space and convert to pixels only at render time. This means that when the user pans or zooms, toCanvasCoordinates() automatically returns the correct updated pixel positions — you never need to manually reproject stored geometries in response to pan/zoom events.

Heavy computational work should never enter this pipeline. If your overlay depends on spatial analysis, pre-process results using building custom processing algorithms or offload them with QgsTask. Blocking the main thread inside paint() causes UI freezes, dropped frames, and degraded experience.

Step-by-Step Implementation

Follow this sequence to build a reliable, maintainable overlay. Each step addresses a specific layer of the rendering contract.

1. Subclass QgsMapCanvasItem

Create a dedicated class that inherits from QgsMapCanvasItem. This base class handles canvas attachment, coordinate conversion, and scene integration. It automatically registers with the canvas viewport and inherits the correct parent-child hierarchy for Qt event propagation.

python
from qgis.gui import QgsMapCanvasItem
from qgis.core import QgsPointXY
from qgis.PyQt.QtCore import QRectF
from qgis.PyQt.QtGui import QPainter, QColor, QPen, QPolygonF


class CustomOverlayItem(QgsMapCanvasItem):
    """Canvas overlay that renders a transient polygon in map-space coordinates.

    Geometry is stored in the canvas CRS and converted to screen pixels only
    during paint() — this pattern ensures correct positions after pan/zoom.
    """

    def __init__(self, canvas) -> None:
        super().__init__(canvas)
        self._points: list[QgsPointXY] = []
        self._color: QColor = QColor(255, 0, 0, 128)

    def set_points(self, points: list[QgsPointXY]) -> None:
        """Replace the overlay geometry and schedule a repaint.

        Args:
            points: Geometry vertices in the canvas CRS coordinate system.
        """
        self._points = list(points)
        self.update()  # Invalidates boundingRect() region and queues paint()

2. Handle Coordinate Transformations

QgsMapCanvasItem provides toCanvasCoordinates(), which converts a map-space QgsPointXY into canvas (screen) coordinates by delegating to the canvas’s internal QgsMapToPixel transform. Always store map coordinates internally and convert to screen pixels only at render time. This guarantees correct positions after every pan, zoom, or CRS change without you having to listen to any canvas signals for reprojection.

If layer geometries arrive in a CRS other than the canvas CRS, apply QgsCoordinateTransform before storing them — otherwise every overlay point will drift against the basemap.

python
def _screen_points(self) -> list:
    """Convert stored map-space points to canvas pixel coordinates.

    Returns:
        List of QPointF objects in device (screen) space, ready for QPainter.
    """
    return [self.toCanvasCoordinates(pt) for pt in self._points]

3. Implement the paint() Method

QgsMapCanvasItem.paint() receives a QPainter plus the standard QGraphicsItem option and widget arguments — it does not receive a QgsRenderContext. Configure the painter, convert stored map points to canvas coordinates, and draw the geometry. Guard against empty data to avoid rendering artifacts, and bracket all state changes with painter.save() / painter.restore() so you never corrupt sibling items’ painter state.

python
def paint(self, painter: QPainter, option=None, widget=None) -> None:
    """Render the overlay polygon onto the canvas viewport.

    Args:
        painter: Active QPainter for the canvas graphics scene.
        option:  Style options forwarded from QGraphicsItem (unused here).
        widget:  Target widget, may be None during offscreen rendering.
    """
    if not self._points:
        return

    painter.save()
    painter.setRenderHint(QPainter.Antialiasing)
    painter.setPen(QPen(self._color, 2))
    painter.setBrush(self._color)

    # Convert map coordinates to screen pixels at paint time, not on storage
    screen_pts = self._screen_points()

    if len(screen_pts) >= 3:
        painter.drawPolygon(QPolygonF(screen_pts))
    elif len(screen_pts) == 2:
        painter.drawLine(screen_pts[0], screen_pts[1])
    elif len(screen_pts) == 1:
        painter.drawEllipse(screen_pts[0], 4, 4)

    painter.restore()

4. Define Accurate Bounding Boxes

The boundingRect() override is critical for canvas invalidation efficiency. Return a QRectF in canvas coordinates that fully encompasses your geometry. Underestimating bounds causes visible clipping; overestimating causes performance degradation because the graphics scene repaints a larger-than-necessary dirty rectangle. Add a small padding buffer (2–4 pixels) to account for stroke width and anti-aliasing bleed.

python
def boundingRect(self) -> QRectF:
    """Return the tight axis-aligned bounding box for scene invalidation.

    Returns:
        QRectF in canvas (screen) coordinates with stroke-width padding.
    """
    if not self._points:
        return QRectF()

    screen_pts = self._screen_points()
    xs = [p.x() for p in screen_pts]
    ys = [p.y() for p in screen_pts]
    rect = QRectF(min(xs), min(ys), max(xs) - min(xs), max(ys) - min(ys))

    # Pad by pen width + anti-aliasing bleed to prevent clipped edges
    return rect.adjusted(-4, -4, 4, 4)

5. Register, Manage, and Clean Up

Attach your item to the canvas and manage its lifecycle carefully. QGIS does not automatically garbage-collect canvas items. Without explicit cleanup you will leak Qt objects, leave orphaned graphics on screen, and experience segmentation faults when the plugin is reloaded.

python
# In your plugin or map tool initialization:
canvas = iface.mapCanvas()
overlay = CustomOverlayItem(canvas)
canvas.scene().addItem(overlay)

# In your plugin unload() or tool deactivate() method — always do both steps:
def cleanup_overlay(canvas, overlay) -> None:
    """Remove the overlay from the scene and schedule Qt object deletion.

    Args:
        canvas:  The QgsMapCanvas the item was registered with.
        overlay: The QgsMapCanvasItem subclass to remove.
    """
    canvas.scene().removeItem(overlay)
    overlay.deleteLater()

For complete API signatures and inheritance constraints, consult the official PyQGIS API Reference for QgsMapCanvasItem.

Advanced Patterns

Interactive Drawing with Mouse Event Capture

Canvas items intercept mouse and keyboard events by overriding mousePressEvent(), mouseMoveEvent(), and keyPressEvent(). This enables interactive drawing tools, snap-to-vertex behavior, and context-sensitive tooltips. Always call super() when you do not consume an event, to preserve default canvas navigation (pan/zoom).

python
from qgis.PyQt.QtCore import Qt


def mousePressEvent(self, event) -> None:
    """Capture left-click in map coordinates and append to the overlay polygon.

    Args:
        event: QGraphicsSceneMouseEvent forwarded from the canvas scene.
    """
    if event.button() == Qt.LeftButton:
        map_pt: QgsPointXY = self.toMapCoordinates(event.pos())
        self._points.append(map_pt)
        self.update()       # Single update after all state mutations
        event.accept()      # Prevent propagation to canvas pan/zoom handler
    else:
        super().mousePressEvent(event)

Decouple event capture from rendering: accumulate raw inputs, validate them against snapping tolerances, then trigger a single update(). This prevents redundant scene invalidations during rapid mouse movements.

Cache-Aware Coordinate Invalidation

For overlays with many vertices, converting every stored point to screen coordinates on every paint() call adds measurable overhead during rapid zoom animations. Cache the screen-coordinate list and invalidate it selectively:

python
from qgis.PyQt.QtCore import pyqtSlot


class CachingOverlayItem(QgsMapCanvasItem):
    """Overlay that caches screen-space coordinates between zoom/pan events."""

    def __init__(self, canvas) -> None:
        super().__init__(canvas)
        self._map_points: list[QgsPointXY] = []
        self._cached_screen: list | None = None
        # Invalidate cache whenever the map extent or scale changes
        canvas.extentsChanged.connect(self._invalidate_cache)

    @pyqtSlot()
    def _invalidate_cache(self) -> None:
        """Drop cached screen coordinates; next paint() will recompute."""
        self._cached_screen = None
        self.update()

    def _screen_points(self) -> list:
        if self._cached_screen is None:
            self._cached_screen = [
                self.toCanvasCoordinates(pt) for pt in self._map_points
            ]
        return self._cached_screen

Connect to signal and slot event handling in QGIS patterns when wiring extentsChanged across multiple overlay instances — disconnecting signals on cleanup is as important as disconnecting canvas items.

Compositing Multiple Overlays with Z-Order

When a plugin renders several overlay types simultaneously — for example a snapping indicator, a measurement polyline, and a buffer preview — manage their stacking order explicitly via setZValue(). Items with higher Z values render on top.

python
SNAP_INDICATOR_Z  = 100   # Always on top
BUFFER_PREVIEW_Z  = 50    # Behind snapping indicator
MEASUREMENT_LINE_Z = 75   # Between the two

snap_item.setZValue(SNAP_INDICATOR_Z)
buffer_item.setZValue(BUFFER_PREVIEW_Z)
measure_item.setZValue(MEASUREMENT_LINE_Z)

Coordinate Z-value assignment alongside plugin lifecycle decisions made in your plugin lifecycle and resource management code to ensure consistent stacking on reload.

Pitfalls and Debugging

  • Overlay disappears on zoom: boundingRect() returns incorrect or stale coordinates, or update() is not called after data changes. Verify that screen-space bounds are recomputed on every call and that update() fires immediately after mutating _points.
  • Coordinates drift during pan: Cached screen coordinates are not invalidated on extentsChanged. Either recompute on every paint() call, or connect extentsChanged to the cache-invalidation slot shown above.
  • UI freezes during render: Heavy computation (spatial queries, raster sampling, network I/O) is running inside paint(). Move all logic to a QgsTask background worker; keep paint() strictly graphical.
  • Memory leak on plugin unload: The canvas item is never removed from the scene. Call scene().removeItem(item) followed by item.deleteLater() in the plugin’s unload() method.
  • Blurry strokes on high-DPI displays: Missing anti-aliasing hint or pen width is not scaled. Enable QPainter.Antialiasing and multiply the nominal pen width by painter.device().devicePixelRatioF().
  • Segfault on canvas deletion: The overlay holds a raw Python reference to the canvas after it has been destroyed by Qt. Always remove the item from the scene before the canvas is closed, and avoid storing the canvas reference beyond the item’s own lifetime.

Use QGIS’s built-in developer tools to trace rendering. Temporarily override boundingRect() to return a visually padded rectangle and fill it with a semi-transparent debug color inside paint() — this makes the invalidation region visible and immediately reveals whether stale caching or underestimated bounds are the culprit.

Conclusion

QgsMapCanvasItem gives PyQGIS developers a direct pathway into the Qt graphics scene with geospatial coordinate conversion built in. The discipline required — store in map space, convert at paint time, return precise bounding boxes, and clean up on unload — is the rendering contract that keeps overlays correct across every zoom level, pan event, and CRS change. By combining the cache-aware coordinate pattern, explicit Z-ordering, and strict separation between background computation and paint(), you can build overlays that remain fluid under large datasets and enterprise workloads.


Related