Skip to content

Add blend modes and blend groups for compositing#31162

Open
ayshih wants to merge 12 commits into
matplotlib:mainfrom
ayshih:agg_compositing
Open

Add blend modes and blend groups for compositing#31162
ayshih wants to merge 12 commits into
matplotlib:mainfrom
ayshih:agg_compositing

Conversation

@ayshih

@ayshih ayshih commented Feb 15, 2026

Copy link
Copy Markdown
Contributor

PR summary

This PR adds support for blend modes beyond alpha blending (e.g., "screen" or "hard light"), so closes #6210. With this PR, all artists can specify blend_mode, and they are supported by Agg-based and Cairo-based backends, and mostly supported by SVG/PDF/PGF backends.

Of course, mplcairo provides access to these blend modes, but this PR provides blend-mode support without needing cairo.

Update: This PR uses this functionality to fix a long-standing bug (e.g., fixes #27016) with Agg rendering of Gouraud shading, where the edges of triangles would become visible when transparency is involved.

Update: This PR adds support for blend groups, which can be isolated, knockout, or both.


Blend modes Agg Cairo SVG PDF PGF PS
normal
multiply, screen, overlay, darken, lighten,
color dodge, color burn, hard light, soft light,
difference, exclusion
hue, saturation, color, luminosity ✅*
knockout, erase, clear, atop, xor, plus
  • "normal" is the normal alpha blending (also known as "over" or "source over")
  • "multiply" through "exclusion" are separable blend modes (color channels are independent)
  • "hue" through "luminosity" are non-separable blend modes
  • "knockout" through "plus" are Porter Duff compositing operators, where "knockout" is also known as "source" and "erase" is also known as "destination out"
  • ✅ = supported, ❌ = not supported
  • * Text artists disappear on some combinations of Cairo version and platform, likely a Cairo bug
Blend groups Agg Cairo SVG PDF PGF PS
isolated only
isolated and knockout
knockout only

Agg showcase

  • Gouraud shading can look wrong with some blend modes, for the same reason it can look wrong under normal blend mode when alpha < 1, due to overlapping triangles Now fixed
agg

Cairo showcase

  • With some combinations of Cairo version and platform, text is missing in non-separable blend modes, which is presumably a bug in Cairo
  • Gouraud shading is apparently not supported by the Cairo backend, so I commented out the pcolormesh call I added support for Gouraud shading

Windows

cairo

macOS

blend_modes_cairo_macos

SVG showcase

  • These results may not render as intended depending on the SVG renderer (try non-mobile web browsers)
  • The Porter Duff compositing operators normally use a different mechanism to command the renderer, which is inaccessible through SVG XML, so are currently disabled (and fall back to "normal" with a warning)

Figure_1

PDF showcase

  • I haven't figured out how to implement the Porter Duff compositing operators

Figure_1.pdf

PGF showcase

  • The PGF and PGF->PDF output looks fine, but the PGF->PNG output via pdftocairo (on Windows) appears to screw up some colors for the non-separable blend modes
  • Gouraud shading is apparently not supported by the PGF backend, so I commented out the pcolormesh call
  • No Porter Duff compositing operators yet again

Figure_1.pgf.pdf

Generating code

import matplotlib
#matplotlib.use('TkCairo')

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Circle, Rectangle

N = 10
data = np.arange(N**2).reshape((N, N)) % (N-1)

fig, axs = plt.subplots(3, 8, figsize=(10, 5.5), layout="tight")
axs = axs.flatten()
fig.set_facecolor("none")

blend_modes = ["normal", "multiply", "screen", "overlay",
               "darken", "lighten", "color dodge", "color burn",
               "hard light", "soft light", "difference", "exclusion",
               "hue", "saturation", "color", "luminosity",
               "knockout", "erase", "clear", "atop", "xor", "plus"]

for ax in axs:
    ax.set_facecolor("none")
    ax.set_xlim(0, 1)
    ax.set_ylim(0, 1.2)
    ax.set_axis_off()

for i, blend_mode in enumerate(blend_modes):
    axs[i].imshow(data, cmap='Reds', alpha=0.75, extent=(0, 0.8, 0, 0.8))
    axs[i].imshow(data[::-1, :], cmap='Blues', alpha=0.75, extent=(0.2, 1, 0.4, 1.2),
                  blend_mode=blend_mode)
    axs[i].pcolormesh(*np.meshgrid(np.linspace(0.6, 0.9, 5), np.linspace(0.7, 1, 5)),
                      data[:5, :5], cmap='Spectral', alpha=0.75, shading='gouraud',
                      blend_mode=blend_mode)
    axs[i].text(0.05, 0.15, "Test", weight="bold", color="c",
                blend_mode=blend_mode)
    axs[i].text(0.35, 0.10, "Tilted", weight="bold", color="m", rotation=45,
                blend_mode=blend_mode)
    axs[i].plot([0.1, 0.1, 0.1, 0.1, 0.2, 0.2, 0.2, 0.2], [0.7, 0.8, 0.9, 1, 0.7, 0.8, 0.9, 1],
                'p', markersize=15, markeredgecolor="orange", markerfacecolor="purple", alpha=0.75,
                blend_mode=blend_mode)
    axs[i].plot([0, 1], [1.2, 0], color="y",
                blend_mode=blend_mode)
    circ = Circle((.65, 0.5), .3, facecolor='g', alpha=0.5,
                  blend_mode=blend_mode, zorder=2)
    axs[i].add_artist(circ)

    rect = Rectangle((0, 1.2), 1, .3, facecolor='lightgray', clip_on=False)
    axs[i].add_artist(rect)
    axs[i].set_title(blend_mode)

plt.show()

Put off to future work:

  • Change the way pcolormesh.snap behaves when the mesh edges are not horizontal/vertical
  • Change the default antialiasing behavior of contourf()/pcolor()/pcolormesh() to be True
  • Agg: investigate alpha edge around Gouraud shading

PR checklist

@ayshih ayshih changed the title WIP: Add blend modes for compositing, supported by Agg backend WIP: Add blend modes for compositing, supported by Agg-based backends Feb 15, 2026
@github-actions github-actions Bot added topic: mpl_toolkit Documentation: API files in lib/ and doc/api labels Feb 15, 2026
@timhoffm

Copy link
Copy Markdown
Member

This looks interesting. Thanks for working on it.

Since I’m not into the topic I can dare to ask the stupid questions:

  • Is it correct that an Artist and its blend mode define completely how they blend with “the background” I.e. all previously drawn artists? In particular, this does not depend on the blend mode of the other artists.
  • Are all these blend modes parameter-less?

@ayshih

ayshih commented Feb 15, 2026

Copy link
Copy Markdown
Contributor Author
  • Is it correct that an Artist and its blend mode define completely how they blend with “the background” I.e. all previously drawn artists? In particular, this does not depend on the blend mode of the other artists.

Yup, that is correct: the history of how that "background" was constructed has no bearing on how the next Artist is blended in using its specific blend mode.

It's also important to remember that that the "empty" background of an Axes is solid white, and thus not actually empty as far as these blend modes are concerned. For example, using "screen" to blend in an image on a truly empty background will just return the image, but on a white background will return solid white, which can make it look instead like the image call failed. That's why I turn off the face colors in my example above.

What I still need to investigate is how my changes interact with collections of Artists. A user may want "over" blending (the default) within the collection before using a different blend mode for the collection as a whole.

  • Are all these blend modes parameter-less?

Yes. In principle, the transformation functions for the hue/saturation/color/luminosity operators could have more than one possibility, but in practice I think everyone has simply used the same functions for decades (as defined in the PDF specification).

@ayshih ayshih force-pushed the agg_compositing branch 2 times, most recently from 2221d69 to 6d49bcf Compare February 16, 2026 04:43
Comment thread lib/matplotlib/artist.py
@anntzer

anntzer commented Feb 16, 2026

Copy link
Copy Markdown
Contributor

This is pretty cool :-)

It's also important to remember that that the "empty" background of an Axes is solid white, and thus not actually empty as far as these blend modes are concerned. For example, using "screen" to blend in an image on a truly empty background will just return the image, but on a white background will return solid white, which can make it look instead like the image call failed. That's why I turn off the face colors in my example above.

What I still need to investigate is how my changes interact with collections of Artists. A user may want "over" blending (the default) within the collection before using a different blend mode for the collection as a whole.

Actually I suspect that another possibility is to want some nonstandard blending between multiple artists, then "over" blending of the result over the background.

In general I suspect this would be related to adding support for temporary, intermediate rendering buffers, which is also something that would be useful for other purposes e.g. contour label overplotting (#26971 (comment)).

@ayshih

ayshih commented Feb 16, 2026

Copy link
Copy Markdown
Contributor Author

By the way, I decided to rename "over" to "normal". That mode of blending is referred to as "normal" often enough, and it makes it readily apparent to users that it is the standard choice (and the default).

@ayshih ayshih force-pushed the agg_compositing branch 4 times, most recently from f49e8d0 to f7af1e4 Compare February 17, 2026 14:15
@ayshih ayshih changed the title WIP: Add blend modes for compositing, supported by Agg-based backends WIP: Add blend modes for compositing, fully supported by Agg backend and mostly supported by others Feb 17, 2026
@ayshih ayshih changed the title WIP: Add blend modes for compositing, fully supported by Agg backend and mostly supported by others WIP: Add blend modes for compositing, fully supported by Agg backends and mostly supported by other major backends Feb 17, 2026
@story645

story645 commented Mar 3, 2026

Copy link
Copy Markdown
Member

but what I'm currently trying out for plotting-level API is a helper class: ArtistGroup in #31162 (comment),

If I'm following that correctly than the PathCollection would need to get wrapped in an ArtistGroup, so something like splatterplot would be implemented as a standalone method rather than an argument to scatter?

Which is fine/ a good example to post to the gallery, just thinking through where/how this is exposed.

@ayshih ayshih force-pushed the agg_compositing branch 4 times, most recently from b04119a to a12b397 Compare March 6, 2026 05:07
@ayshih

ayshih commented Mar 6, 2026

Copy link
Copy Markdown
Contributor Author

Per the discussion at today's dev meeting, the renderer methods to open/close an isolated transparency group are implemented as new methods open_blend_group()/close_blend_group() rather than expanding the existing methods open_group()/close_group(). The separation is definitely cleaner, especially with the docstring.

My niggling concern is that both open_group() and open_blend_group() accept a string as the sole required argument, a developer could get tripped up by accidentally typing, e.g., open_group("difference") instead of open_blend_group("difference") and being mystified why it's not blending as intended. Do folks think it would be reasonable for me to have open_group() check whether the specified group name is equal to the name of a blend mode, and if it is, emit a warning that the developer that open_blend_group() is probably what was intended to be called?

@anntzer

anntzer commented Mar 6, 2026

Copy link
Copy Markdown
Contributor

The problem is that the main(?) point of open_group is to allow naming of svg groups (<g>); I assume that if third-parties are using those the group names tend to be programmatically set (e.g. matching a preexisting object hierarchy) so it would be annoying to hit warnings. If you are really worried about the potential confusion (I'm not so sure about that) I would suggest making the parameters to open_group keyword-only, which should clarify the matter...

@ayshih

ayshih commented Mar 6, 2026

Copy link
Copy Markdown
Contributor Author

Yeah, I think you're right on not putting in a warning. The false positive of emitting a warning when someone legitimately wants to name an SVG group something totally reasonable like "overlay" is not worth mitigating the hypothetical confusion of someone mistakenly calling open_group() instead of open_blend_group(). When the group isn't blending as they desire, hopefully they pull up the docstring on the method they are calling and that will immediately reveal the error to them.

@ayshih

ayshih commented Mar 9, 2026

Copy link
Copy Markdown
Contributor Author

I've implemented two more Porter-Duff compositing operators – "source" and "clear" – for both Agg and Cairo backends, but I think "source" is too confusing of a name. Since "source" provides the similar behavior as a knockout group, I have renamed it "knockout" for blend_mode.

@ayshih

ayshih commented Mar 10, 2026

Copy link
Copy Markdown
Contributor Author

I've now implemented that a blend group can be an isolated group, a knockout group, both, or neither. Isolated groups are supported by all non-PS backends. Knockout groups are fully supported by only the PDF and PGF backends and partially supported by Agg and Cairo backends (cannot do non-isolated and knockout at the same time). If a knockout group is specified and the backend does not support it, the group will be rendered as non-knockout (in the images below, the panel on the right falls back to the panel to the left).

Blend groups Agg Cairo SVG PDF PGF PS
isolated only
isolated and knockout
knockout only

Agg

Top-right panel is not supported
agg

Cairo

Top-right panel is not supported
cairo

SVG

Right column is not supported
Figure_1

PDF

All supported
Figure_1.pdf

PGF

All supported
Figure_1.pgf.pdf

Generating code

import matplotlib
#matplotlib.use('TkCairo')

import numpy as np

import matplotlib.pyplot as plt
from matplotlib.artist import Artist
from matplotlib.patches import Circle

class ArtistGroup(Artist):
    def __init__(self, artist_list, *, group_blend_mode=None, group_alpha=1, knockout=False):
        self._artist_list = artist_list
        self._group_blend_mode = group_blend_mode
        self._group_alpha = group_alpha
        self._knockout = knockout
        super().__init__()

    def draw(self, renderer):
        renderer.open_blend_group(self._group_blend_mode, alpha=self._group_alpha, knockout=self._knockout)
        for a in self._artist_list:
            if not a.is_transform_set():
                a.set_transform(self.get_transform())
            a.draw(renderer)
        renderer.close_blend_group()


fig, axs = plt.subplots(2, 2, figsize=(6, 6), layout='constrained')
fig.set_facecolor("none")

for i, group_blend_mode in enumerate([None, "normal"]):
    for j, knockout in enumerate([False, True]):
        axs[i, j].set_facecolor("none")
        axs[i, j].set_xlim(-1, 1)
        axs[i, j].set_ylim(-1, 1)
        axs[i, j].set_aspect("equal")

        axs[i, j].imshow(np.arange(20*20).reshape((20, 20)) % 19,
                      cmap='Spectral', extent=[-1, 1, -1, 1])

        left = Circle((-0.25, 0), 0.6, fc='y', alpha=0.75, blend_mode='multiply')
        right = Circle((0.25, 0), 0.6, fc='g', alpha=0.75, blend_mode='multiply')

        both = ArtistGroup([left, right], group_blend_mode=group_blend_mode, knockout=knockout)
        axs[i, j].add_artist(both)

axs[0, 0].set_title('non-knockout')
axs[0, 1].set_title('knockout')
axs[0, 0].set_ylabel('non-isolated')
axs[1, 0].set_ylabel('isolated')

plt.show()

@ayshih

ayshih commented Mar 12, 2026

Copy link
Copy Markdown
Contributor Author

Update: Now reverted, but I put the function at the bottom of this post in case someone in the future wants to give this approach another try

I had been hoping to use the blend modes and blend groups to fix the issue of visible grid lines of pcolormesh with the PDF backend, as worked so well for the Agg/Cairo backends (#31162 (comment)). However, nothing in that vein really panned out. Instead, an approach that works is to implement a RendererPdf.draw_quad_mesh() that takes advantage of the fact that the existing draw_gouraud_triangles() code already specifies each Gouraud triangle in a disconnected fashion, even for adjacent triangles. So, draw_quad_mesh() creates a Gouraud mesh where each triangle is flat in color and adjacent triangles can have different colors even at shared vertices.

I will actually likely revert this change because the alignment and edge aliasing is worse with this change, but it does get rid of the visible grid lines.

Before this PR

Figure_1-before.pdf

After this PR

Figure_1-after.pdf


Preserving the attempt for posterity:

    def draw_quad_mesh(self, gc, master_transform, meshWidth, meshHeight,
                       coordinates, offsets, offsetTrans, facecolors,
                       antialiased, edgecolors):
        # Use the fallback drawing of each quad as a path if edges are stroked
        if (edgecolors.size != 0 and edgecolors[0][3] != 0):
            super().draw_quad_mesh(gc, master_transform, meshWidth, meshHeight,
                                   coordinates, offsets, offsetTrans, facecolors,
                                   antialiased, edgecolors)
            return

        # Otherwise, leverage the Gouraud code path with each triangle having
        # three vertices of the same color, but common vertices of adjacent
        # triangles can have different colors

        if isinstance(coordinates, np.ma.MaskedArray):
            p = coordinates.data
        else:
            p = coordinates

        # TODO: adjust for offsets and offsetTrans

        p_a = p[:-1, :-1]
        p_b = p[:-1, 1:]
        p_c = p[1:, 1:]
        p_d = p[1:, :-1]
        triangles = np.concatenate([
            p_a, p_b, p_c,
            p_c, p_a, p_d,
        ], axis=2).reshape((-1, 3, 2))

        colors = np.repeat(facecolors, 6, axis=0).reshape((-1, 3, 4))
        empty = (colors[:, 0, 3] == 0)

        self.draw_gouraud_triangles(gc, triangles[~empty], colors[~empty],
                                    master_transform)

@ayshih

ayshih commented May 7, 2026

Copy link
Copy Markdown
Contributor Author

Ugh, trying to have a Cairo figure test is a disaster.

The Cairo bug with missing text is not limited to Windows, but I don't have a handle on the common thread (and there may be multiple issues at play).

From the CI:

Platform libcairo pycairo Works
Ubuntu 22.04 1.16.0 (via apt) 1.29.0
Ubuntu 24.04 1.18.0 (via apt) 1.29.0
macOS arm64 1.18.4 (via apt?) 1.29.0

From my own machines:

Platform libcairo pycairo Works
macOS x86_64 1.18.4 (via conda-forge) 1.29.0
Windows 1.18.4 1.29.0

I have not yet tried to vary the installed versions, but I feel like there are other packages that matter or the precise way that cairo 1.18.4 was built matters.

Even setting aside the bug, the text is rendered slightly differently between the CI builds. And even ignoring all text-related discrepancies, the blending is a bit different on the edges between the macOS ARM builds and the Ubuntu builds. The three categories of builds are running different versions of libcairo, so the blending differences might be due to that, but that still inhibits a common image test.

Image diff between Ubuntu 22.04 and Ubuntu 24.04
blend_modes_cairo-failed-diff
Image diff between Ubuntu 22.04 and macOS arm64
blend_modes_cairo-failed-diff

@anntzer

anntzer commented May 7, 2026

Copy link
Copy Markdown
Contributor

I didn't know, but am not really surprised that cairo's exact rendering depends on the details of the build. Looks like the difference is mostly related to antialiasing, i.e. the colors in the "middle" of the overlaying patches still match, so perhaps 1) disabling antialiasing throughout may help (set_antialias(False) on the patches)?, or 2) instead of an image comparison test, the test could be to draw two partially overlapping squares, then extract the rgba buffer and simply test the rgba values for a pixel in the middle of the first square, for a pixel in the middle of the second square, and for a pixel in the overlaying region.
(Getting texts to match across versions is pretty much hopeless (I don't know if cairo picks up matplotlib's forced freetype version -- likely not?) and I would just get rid of them in the test images.)

@ayshih

ayshih commented May 7, 2026

Copy link
Copy Markdown
Contributor Author

Here is how I have decided to resolve the Cairo image test:

  • Remove all text from the test image generated for Cairo. All other backends continue to test text.
  • Use the macOS arm64 output as the reference image (zero tolerance) because it is the only category of CI builds that actually install the latest version of cairo (1.18.4)
  • For the CI builds that run an older version of cairo (i.e., 1.16.0 or 1.18.0 running on Ubuntu), the non-text rendering discrepancies smell like bugs in Cairo rendering prior to 1.18.4, so I bump the tolerance up to 3 to account for them, and I think 3 is still low enough to be somewhat useful. For comparison the rendering difference when text is on the image is >18.

@ayshih

ayshih commented May 8, 2026

Copy link
Copy Markdown
Contributor Author

This PR is ready for comprehensive review! The remaining open question in my mind is whether the helper ArtistGroup class that I define on the documentation page for blend groups is appropriate and robust enough to live in the library proper. But, that decision could also be put off until the blend-group functionality has been out in the wild for a while.

Here's an example of using the "screen" blend mode to composite two images of the Sun at different wavelengths (171 angstroms in yellow, 211 angstroms in purple):
example

@story645

Copy link
Copy Markdown
Member

call nit about docs: Can you combine the examples into a "blending alpha" user guide entry under color. Alpha is introduced in the colors explanation so I think goes under the "colors" category.

@ayshih

ayshih commented May 15, 2026

Copy link
Copy Markdown
Contributor Author

I have relocated the two documentation pages to under "Colors" in the user guide, and linked to the blend-modes page from the existing description of normal alpha blending.

Also, the conclusion at yesterday's dev meeting was that – for this PR – the helper class (ArtistGroup) can live on just the documentation page rather than being moved to the library proper. The thinking is that there ought to be a future discussion of the requirements and design of a fully realized "compound artist" class, and how that may intersect with or replace the existing Container class, but that is all beyond the scope of this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: diagonal lines in pcolormesh with Gouraud shading and transparency Alternative compositing methods

4 participants