QPainter CompositionMode or Interactive PseudoColor Mapping

How to build an interactive paint tool with additive blending and pseudocolor mapping
Heads up! You've already completed this tutorial.

I want to paint shapes (rectangles) over each other so that the overlapping regions become brighter than non-overlapping regions, and then apply a color map to colorize the result based on intensity — like a heatmap. I've been trying QPainter CompositionModes but can't get the result I'm looking for. How can this be accomplished interactively?

This is a great question, and it touches on a pattern that comes up often when building heatmap-style visualizations or additive painting tools. The idea is:

  1. Paint shapes onto a canvas, where overlapping regions accumulate intensity.
  2. Map those accumulated intensity values through a color lookup table (a pseudocolor map) to produce a colorful result.

QPainter's CompositionMode options can do additive blending, but they operate on RGBA color channels directly. That means you're adding color values together, not building up a single intensity channel that you later colorize. To get the heatmap-style effect, you need a slightly different approach: paint onto a grayscale intensity buffer, then map that buffer through a color table for display.

Let's walk through how to do this step by step.

The two-buffer approach

The core idea is to maintain two images:

  • An intensity buffer — a grayscale QImage where each pixel holds a single value from 0 to 255. When you paint a shape here, overlapping regions get brighter because their values add together.
  • A display image — a full-color QImage that you generate by mapping each pixel in the intensity buffer through a color lookup table.

This separation gives you full control over both the accumulation and the colorization. If you're new to drawing with QPainter, our QPainter bitmap graphics tutorial covers the fundamentals of working with QImage, QPixmap, and painting operations.

Setting up the intensity buffer

We'll use a QImage with the format Format_Grayscale8. Each pixel in this format is a single byte (0–255), which is perfect for accumulating intensity values.

python
from PyQt6.QtGui import QImage

intensity_buffer = QImage(400, 300, QImage.Format.Format_Grayscale8)
intensity_buffer.fill(0)  # Start with all black (zero intensity)

Painting with additive blending

To paint a rectangle onto the intensity buffer and have overlapping areas accumulate, we use QPainter with the composition mode CompositionMode_Plus. This mode adds the source pixel values to the destination pixel values, which is exactly what we want.

python
from PyQt6.QtCore import QRect
from PyQt6.QtGui import QColor, QPainter

painter = QPainter(intensity_buffer)
painter.setCompositionMode(
    QPainter.CompositionMode.CompositionMode_Plus
)
painter.setPen(Qt.PenStyle.NoPen)
painter.setBrush(QColor(60, 60, 60))  # A dim gray — each layer adds 60 to intensity
painter.drawRect(QRect(50, 50, 150, 100))
painter.end()

When you paint a second rectangle that overlaps the first, the overlapping pixels will have their values summed. If each rectangle contributes an intensity of 60, two overlapping rectangles produce 120, three produce 180, and so on — up to the maximum of 255.

Building a color lookup table

Now we need a way to turn those grayscale intensity values into colors. A color lookup table is simply a list of 256 QRgb values, one for each possible intensity level.

Here's a function that creates a simple blue → green → red gradient (similar to a "jet" colormap):

python
from PyQt6.QtGui import qRgb


def build_colormap():
    """Build a 256-entry color table: black -> blue -> green -> yellow -> red."""
    table = []
    for i in range(256):
        if i == 0:
            table.append(qRgb(0, 0, 0))  # Zero intensity = black
        elif i < 85:
            # Blue to green
            t = i / 85.0
            r = 0
            g = int(255 * t)
            b = int(255 * (1 - t))
            table.append(qRgb(r, g, b))
        elif i < 170:
            # Green to yellow
            t = (i - 85) / 85.0
            r = int(255 * t)
            g = 255
            b = 0
            table.append(qRgb(r, g, b))
        else:
            # Yellow to red
            t = (i - 170) / 85.0
            r = 255
            g = int(255 * (1 - t))
            b = 0
            table.append(qRgb(r, g, b))
    return table

Applying the color table to the intensity buffer

To apply the color table, we convert the grayscale buffer to an Format_Indexed8 image and set its color table. Qt's indexed image format is designed for exactly this — each pixel value is an index into a table of colors.

python
def apply_colormap(intensity_buffer, color_table):
    """Convert a grayscale intensity buffer into a colorized indexed image."""
    # Convert grayscale to indexed8 format
    indexed = intensity_buffer.convertToFormat(QImage.Format.Format_Indexed8)
    indexed.setColorTable(color_table)
    # Convert to ARGB32 for display
    return indexed.convertToFormat(
        QImage.Format.Format_ARGB32_Premultiplied
    )

The final conversion to ARGB32_Premultiplied ensures the image is in a format that paints efficiently onto widgets.

Putting it all together: an interactive painting widget

Let's build a complete interactive application. You can click and drag to paint rectangles onto the canvas, and you'll see the pseudocolor heatmap update in real time. Each rectangle you paint adds to the intensity, and overlapping areas become progressively brighter and shift through the color map. This example builds on the concept of creating your own custom widgets by subclassing QWidget and handling paint and mouse events.

python
import sys

from PyQt6.QtCore import QPoint, QRect, Qt
from PyQt6.QtGui import QColor, QImage, QPainter, QPixmap, qRgb
from PyQt6.QtWidgets import QApplication, QLabel, QMainWindow, QVBoxLayout, QWidget


def build_colormap():
    """Build a 256-entry color table: black -> blue -> green -> yellow -> red."""
    table = []
    for i in range(256):
        if i == 0:
            table.append(qRgb(0, 0, 0))
        elif i < 85:
            t = i / 85.0
            r = 0
            g = int(255 * t)
            b = int(255 * (1 - t))
            table.append(qRgb(r, g, b))
        elif i < 170:
            t = (i - 85) / 85.0
            r = int(255 * t)
            g = 255
            b = 0
            table.append(qRgb(r, g, b))
        else:
            t = (i - 170) / 85.0
            r = 255
            g = int(255 * (1 - t))
            b = 0
            table.append(qRgb(r, g, b))
    return table


def apply_colormap(intensity_buffer, color_table):
    """Convert a grayscale intensity buffer into a colorized image."""
    indexed = intensity_buffer.convertToFormat(QImage.Format.Format_Indexed8)
    indexed.setColorTable(color_table)
    return indexed.convertToFormat(QImage.Format.Format_ARGB32_Premultiplied)


class PaintCanvas(QWidget):
    CANVAS_WIDTH = 600
    CANVAS_HEIGHT = 400
    BRUSH_SIZE = 40
    BRUSH_INTENSITY = 30  # Amount added per paint stroke

    def __init__(self):
        super().__init__()
        self.setFixedSize(self.CANVAS_WIDTH, self.CANVAS_HEIGHT)

        # The grayscale intensity buffer
        self.intensity_buffer = QImage(
            self.CANVAS_WIDTH, self.CANVAS_HEIGHT,
            QImage.Format.Format_Grayscale8,
        )
        self.intensity_buffer.fill(0)

        # Build the color lookup table once
        self.color_table = build_colormap()

        # The colorized display image
        self.display_image = apply_colormap(
            self.intensity_buffer, self.color_table
        )

        self.painting = False

    def paint_at(self, position):
        """Paint a rectangle centered on the given position into the intensity buffer."""
        half = self.BRUSH_SIZE // 2
        rect = QRect(
            position.x() - half,
            position.y() - half,
            self.BRUSH_SIZE,
            self.BRUSH_SIZE,
        )

        painter = QPainter(self.intensity_buffer)
        painter.setCompositionMode(
            QPainter.CompositionMode.CompositionMode_Plus
        )
        painter.setPen(Qt.PenStyle.NoPen)
        gray_value = self.BRUSH_INTENSITY
        painter.setBrush(QColor(gray_value, gray_value, gray_value))
        painter.drawRect(rect)
        painter.end()

        # Regenerate the colorized display image
        self.display_image = apply_colormap(
            self.intensity_buffer, self.color_table
        )
        self.update()

    def mousePressEvent(self, event):
        if event.button() == Qt.MouseButton.LeftButton:
            self.painting = True
            self.paint_at(event.position().toPoint())

    def mouseMoveEvent(self, event):
        if self.painting:
            self.paint_at(event.position().toPoint())

    def mouseReleaseEvent(self, event):
        if event.button() == Qt.MouseButton.LeftButton:
            self.painting = False

    def paintEvent(self, event):
        painter = QPainter(self)
        painter.drawImage(0, 0, self.display_image)
        painter.end()


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.setWindowTitle("Additive Paint with PseudoColor Mapping")

        layout = QVBoxLayout()

        instructions = QLabel(
            "Click and drag to paint. Overlapping areas accumulate intensity "
            "and shift through the color map."
        )
        instructions.setWordWrap(True)
        layout.addWidget(instructions)

        self.canvas = PaintCanvas()
        layout.addWidget(self.canvas)

        container = QWidget()
        container.setLayout(layout)
        self.setCentralWidget(container)


app = QApplication(sys.argv)
window = MainWindow()
window.show()
sys.exit(app.exec())

Run this and start painting by clicking and dragging on the canvas. You'll see that areas where you paint once appear blue, painting over the same area shifts the color toward green, then yellow, and finally red as the intensity accumulates. This gives you a real-time, interactive heatmap.

How it works

The flow is straightforward:

  1. Mouse events call paint_at(), which draws a small gray rectangle onto the Format_Grayscale8 intensity buffer using CompositionMode_Plus.
  2. Because of the Plus composition mode, each new rectangle adds to what's already there. Paint the same spot twice and the pixel values increase.
  3. After each paint stroke, apply_colormap() converts the grayscale buffer to an indexed image, applies the color lookup table, and produces a full-color display image.
  4. The widget's paintEvent() draws that display image to the screen.

Customizing the color map

You can modify build_colormap() to produce any color scheme you like. For example, here's a simple "hot" colormap that goes from black through red to white:

python
def build_hot_colormap():
    """Black -> red -> yellow -> white."""
    table = []
    for i in range(256):
        if i < 85:
            t = i / 85.0
            table.append(qRgb(int(255 * t), 0, 0))
        elif i < 170:
            t = (i - 85) / 85.0
            table.append(qRgb(255, int(255 * t), 0))
        else:
            t = (i - 170) / 85.0
            table.append(qRgb(255, 255, int(255 * t)))
    return table

Swap it in by replacing the build_colormap() call in PaintCanvas.__init__().

Adjusting brush intensity

The BRUSH_INTENSITY constant controls how much intensity each paint stroke adds. A value of 30 means you can paint over the same spot about 8 times before it reaches maximum (30 × 8 = 240 ≈ 255). Lower values give you more granularity; higher values saturate faster.

You could also vary the intensity per-stroke, or use circular brushes instead of rectangles — the same principle applies. Paint onto the grayscale buffer with additive blending, then map through your color table.

Why CompositionMode alone wasn't enough

Going back to the original question: CompositionMode_Plus does perform additive blending, and it works well for accumulating values. The missing piece was the color mapping step. Without it, additive blending on a color image just makes things brighter (toward white). By working with a grayscale intensity buffer and applying a color lookup table as a separate step, you get the pseudocolor heatmap effect where different intensity levels map to distinct colors.

For more data visualization approaches in PyQt6, you might also want to explore plotting with PyQtGraph which provides built-in support for colormaps, heatmaps, and interactive charts, or learn about PyQt6 signals and slots to connect your custom painting widget to other parts of your application.

Well done, you've finished this tutorial! Mark As Complete
[[ user.completed.length ]] completed [[ user.streak+1 ]] day streak

Packaging Python Applications with PyInstaller by Martin Fitzpatrick

This step-by-step guide walks you through packaging your own Python applications from simple examples to complete installers and signed executables.

More info Get the book

Martin Fitzpatrick

QPainter CompositionMode or Interactive PseudoColor Mapping was written by Martin Fitzpatrick.

Martin Fitzpatrick has been developing Python/Qt apps for 8 years. Building desktop applications to make data-analysis tools more user-friendly, Python was the obvious choice. Starting with Tk, later moving to wxWidgets and finally adopting PyQt. Martin founded PythonGUIs to provide easy to follow GUI programming tutorials to the Python community. He has written a number of popular Python books on the subject.