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:
- Paint shapes onto a canvas, where overlapping regions accumulate intensity.
- 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
- Setting up the intensity buffer
- Painting with additive blending
- Building a color lookup table
- Applying the color table to the intensity buffer
- Putting it all together: an interactive painting widget
- How it works
- Customizing the color map
- Adjusting brush intensity
- Why CompositionMode alone wasn't enough
The two-buffer approach
The core idea is to maintain two images:
- An intensity buffer — a grayscale
QImagewhere 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
QImagethat 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.
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.
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):
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.
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.
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:
- Mouse events call
paint_at(), which draws a small gray rectangle onto theFormat_Grayscale8intensity buffer usingCompositionMode_Plus. - Because of the
Pluscomposition mode, each new rectangle adds to what's already there. Paint the same spot twice and the pixel values increase. - 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. - 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:
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.
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.