Skip to content

Add blend modes and blend groups for compositing - #31162

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

Add blend modes and blend groups for compositing#31162
ayshih wants to merge 20 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.


✅ = supported, 🟡 = supported through rasterization, ❌ = not supported

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"
  • * Text artists disappear on some combinations of Cairo version and platform, likely a Cairo bug
Blend groups Agg Cairo SVG PDF PGF PS
neither isolated nor knockout (same as no group)
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, "Horizontal", 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 Outdated
@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
@ayshih

ayshih commented Jul 9, 2026

Copy link
Copy Markdown
Contributor Author

Updates over the past two weeks:

  • In the documentation, I now discuss rasterization as an option to achieve blending results that are not natively supported by the particular vector backend, which greatly reduces the number of ❌ in the support tables. I also added figure tests for rasterization.
  • I chose better colors for the blend-groups documentation so that it is easier to see the differences between the options.
  • For non-isolated knockout blend groups (which not supported in general by Agg, which is a problem for the rendered documentation), I now show a workaround that can be used for Agg – and hence can be shown in the rendered documentation – if all the artists in the group have the same blend mode.
  • Buried in the thread above, I noted that sometimes the edges of two adjacent contour regions are not exactly coincident. I tracked that down to a snapping-related bug that is fixed by Fix a bug of ignoring the closing path segment when automatically determining whether to snap #32018.

@story645 story645 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So big picture is this is fantastic and thank you for all the work and sticking with it.

While reviewing the C++, I was struggling a bit with which formulas each section is implementing. Can you add some comments in the C++ with short forms of the formulas you're implementing? I tried to use copilot and searching for some of them, but was a bit muddled, and I think adding the formulas will make it easier to make sure things stay in sync.

Not sure where to add it, but the note about how "not supported" means "no easy path to implementation. Which in hindsight, this PR would probably be easier to review if it was broken out by backend - feature comes in w/ agg/cairo, then pdf/pgf, then svg. And I mentioned this in the review comments too, but l think this pr is so large and indepth that the motivating examples of antialiasing and gourard(sp?) triangles should be pushed out into their own prs b/c they require additional context on top of this PR.

Also I think all of this should get a giant provisional flag for now? attn @timhoffm

Comment on lines +4 to +5
There are now alternative options for blending and compositing artists on top
of previously drawn artists, instead of the normal alpha blending. The

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
There are now alternative options for blending and compositing artists on top
of previously drawn artists, instead of the normal alpha blending. The
There are now alternative options, to normal alpha blending, for blending and compositing artists on top of previously drawn artists. The

I dunno that I like my suggestion either, but this sentence feels a bit wonky

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now reworded

Comment thread extern/agg24-svn/include/agg_color_rgba.h
Comment thread extern/agg24-svn/include/agg_color_rgba.h
Comment thread extern/agg24-svn/include/agg_color_rgba.h Outdated
Comment thread extern/agg24-svn/include/agg_color_rgba.h
Comment thread lib/matplotlib/image.py
for a in artists:
if (isinstance(a, _ImageBase) and a.can_composite() and
a.get_clip_on() and not a.get_clip_path()):
a.get_clip_on() and not a.get_clip_path() and

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

so any non normal blend mode should trigger a draw?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yup. This code path is about collapsing a sequence of images into a single image so that the renderer needs to be called for only one image, which is done with "normal" blending. Essentially it acts like an isolated blend group where the group blend mode is locked to "normal". That means that if any image has a blend mode other than "normal", it cannot be included in a group. So, in that case, we draw the group of the preceding images, and then render that next image separately.

Comment thread src/agg_workaround.h
if(alpha == 0) return;

// The following code does not accurately transform to higher bit depth
// TODO: Improve the accuracy of this code when we are prepared to regenerate all baseline images

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this should probably be converted to an issue when this pr gets merged?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In principle, yes, but it's an incredibly subtle difference

Comment thread lib/matplotlib/tests/test_backends_rendering.py
Comment thread lib/matplotlib/tests/test_backends_rendering.py
axs[i].set_title(blend_mode)


class ArtistGroup(Artist):

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I know this seems silly, but can you add a test of your testing helper? that artist group properly groups?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've enhanced the blend-group image test so that it checks that the combined output of artists of multiple zorders is rendered at a single zorder

@ayshih

ayshih commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

So big picture is this is fantastic and thank you for all the work and sticking with it.

Thanks for reviewing it! =)

While reviewing the C++, I was struggling a bit with which formulas each section is implementing. Can you add some comments in the C++ with short forms of the formulas you're implementing? I tried to use copilot and searching for some of them, but was a bit muddled, and I think adding the formulas will make it easier to make sure things stay in sync.

I will make this more clear in the comments, but the four non-separable blend modes I've added to Agg are a near-literal implementation of the pseudocode provided in the PDF specification (e.g., pages 326–328 of PDF 1.7). That includes the structure of the code and functions, the naming of variables, and the magic numbers. This exact pseudocode underpins not only the implementations of every PDF renderer, but also any renderer that plans to produce matching output (e.g., Cairo's page about their blending/compositing operators parrots the same pseudocode). As such, I think that it is important to mirror the pseudocode as closely as reasonably possible.

Not sure where to add it, but the note about how "not supported" means "no easy path to implementation.

Once this PR is merged, I envision a follow-up post to #6210 that lists every "not supported" case and the reason why. Some enterprising individual in the future may come up with a solution given existing constraints, or perhaps newer versions of specifications (e.g., SVG or PDF) may enable implementation. I don't think any discussion of implementation difficulties should go into user-facing documentation.

Which in hindsight, this PR would probably be easier to review if it was broken out by backend - feature comes in w/ agg/cairo, then pdf/pgf, then svg.

I used to have separate commits for each backend, but I merged them into one commit because the changes to each backend were already completely decoupled at the file level.

And I mentioned this in the review comments too, but l think this pr is so large and indepth that the motivating examples of antialiasing and gourard(sp?) triangles should be pushed out into their own prs b/c they require additional context on top of this PR.

I've intentionally kept those fixes as separate commits, so they could certainly be split off to subsequent PRs. That said, I am hesitant to do so because that would reduce this PR to be largely of "hypothetical" use, which I fear would mean that reviewers are even less likely to be motivated to look at it.

Also I think all of this should get a giant provisional flag for now?

I don't know what the term "provisional" means for matplotlib, but I certainly would not consider this functionality to be "provisional". Given that the output is as desired in nearly all cases, is the concern that the API might change?

@story645

Copy link
Copy Markdown
Member

Given that the output is as desired in nearly all cases, is the concern that the API might change?

Yes, provisional is just our flag for "this API might change" and I'll open an issue about how we should add info about it to the dev docs. There's an example in colorizer

. That said, I am hesitant to do so because that would reduce this PR to be largely of "hypothetical" use, which I fear would mean that reviewers are even less likely to be motivated to look at it.

I don't think so since you can point to the uses directly. I think right now the lack of motivation is solely b/c of the size and scope of this PR - it's a bit hard to find/keep bearings while doing the review if you don't already have it in your head.

Once this PR is merged, I envision a follow-up post to #6210 that lists every "not supported" case and the reason why. ... I don't think any discussion of implementation difficulties should go into user-facing documentation.

Sounds good to me.

As such, I think that it is important to mirror the pseudocode as closely as reasonably possible.

That's fine so long as the sourcing is more explicit in the comments. Copilot and google at no point pulled this up,

@ayshih

ayshih commented Jul 15, 2026

Copy link
Copy Markdown
Contributor Author

Given that the output is as desired in nearly all cases, is the concern that the API might change?

Yes, provisional is just our flag for "this API might change"

I think it incredibly unlikely that the blend-mode API would change. There might be reason to change the blend-group API, but that also seems unlikely to me.

If I were trying to add ArtistGroup to the API, I definitely would slap a giant "provisional" warning on that.

@story645

Copy link
Copy Markdown
Member

I think it incredibly unlikely that the blend-mode API would change.

I'm not sure we've ever actually changed our provisional API (subplot_mosaic was provisional for years), it's just a kind of emergency escape/hedge on mostly big changes.

Comment thread extern/agg24-svn/include/agg_pixfmt_rgba.h
@ayshih

ayshih commented Jul 17, 2026

Copy link
Copy Markdown
Contributor Author

I have moved the fixes for contourf and pcolor/pcolormesh out of this PR to make this PR slightly more wieldy to review. The bugs are less apparent to users because it requires antialiasing to be turned on, which is not the default. I'll PR those fixes after this PR is merged.

I have retained the fix to Agg Gouraud shading because the bug is easier to be encountered by users and would be immediately seen in the blend-mode gallery.

Comment thread lib/matplotlib/artist.py Outdated
Comment on lines +1500 to +1501
if blend_mode not in _BLEND_MODES_PDFSPEC + _BLEND_MODES_PORTERDUFF:
raise ValueError(f"{blend_mode} is not a supported blend mode")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should use _api.check_in_list.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since the blend modes are now a StrEnum, it felt cleaner to me to stick with using in rather than _api.check_in_list(), but let me know if you disagree

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The upside of check_in_list is that we get a nice error message that suggests near miss-spelling which makes the error message way nice for the user if they happen to be passing strings rather than the Enum objects.

We have been trying to upgrade every error message we have to do this consistently.

@ayshih ayshih Aug 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, indeed, and it works nicely with StrEnum too. I've updated to use check_in_list(), and I realized I hadn't even been checking the input for open_blend_group(), so that's now protected too.

Comment thread lib/matplotlib/artist.py Outdated
Comment thread src/_backend_agg.h
Comment thread lib/matplotlib/backends/backend_agg.py Outdated
Start filtering. It simply creates a new canvas (the old one is saved).
"""
self._filter_renderers.append(self._renderer)
self._group_states.append(("filter", self._renderer, None, None))

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe it would be useful to make it a namedtuple?

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

7 participants