diff --git a/lib/matplotlib/backends/backend_qtagg.py b/lib/matplotlib/backends/backend_qtagg.py index 54efb134c2b1..9c3db52da529 100644 --- a/lib/matplotlib/backends/backend_qtagg.py +++ b/lib/matplotlib/backends/backend_qtagg.py @@ -4,9 +4,10 @@ from matplotlib.transforms import Bbox +from matplotlib.backend_bases import DrawEvent from .qt_compat import QT_API, QtCore, QtGui -from .backend_agg import FigureCanvasAgg +from .backend_agg import FigureCanvasAgg, RendererAgg from .backend_qt import _BackendQT, FigureCanvasQT from .backend_qt import ( # noqa: F401 # pylint: disable=W0611 FigureManagerQT, NavigationToolbar2QT) @@ -14,6 +15,49 @@ class FigureCanvasQTAgg(FigureCanvasAgg, FigureCanvasQT): + def __init__(self, figure=None): + super().__init__(figure=figure) + self._layer_renderers = {} + self._renderer_key = None + + def draw(self): + """ + Render the figure using the per-layer caching optimization. + """ + fig = self.figure + w, h = self.get_width_height(physical=True) + dpi = fig.dpi + + # Run layout engine once before drawing + if fig.axes and fig.get_layout_engine() is not None: + try: + fig.get_layout_engine().execute(fig) + except ValueError: + pass + + key = (w, h, dpi) + is_resized = self._renderer_key != key + if is_resized: + self._renderer_key = key + + for layer_name in fig._children_by_layer: + layer_stale = fig._stale_layers.get(layer_name, True) + + # Re-render if: layer is stale OR canvas was resized + if layer_stale or is_resized: + layer_renderer = RendererAgg(w, h, dpi) + fig._draw_layer(layer_renderer, layer_name) + + self._layer_renderers[layer_name] = layer_renderer + + fig.stale = False + + # Fire the draw event + base_renderer = self._layer_renderers.get("base") + DrawEvent("draw_event", self, base_renderer)._process() + + self.update() + def paintEvent(self, event): """ Copy the image from the Agg canvas to the qt.drawable. @@ -23,9 +67,8 @@ def paintEvent(self, event): """ self._draw_idle() # Only does something if a draw is pending. - # If the canvas does not have a renderer, then give up and wait for - # FigureCanvasAgg.draw(self) to be called. - if not hasattr(self, 'renderer'): + # If the layers haven't been rendered yet, give up and wait for draw() + if not self._layer_renderers: return painter = QtGui.QPainter(self) @@ -46,21 +89,29 @@ def paintEvent(self, event): right = left + width # create a buffer using the image bounding box bbox = Bbox([[left, bottom], [right, top]]) - buf = memoryview(self.copy_from_bbox(bbox)) - - if QT_API == "PyQt6": - from PyQt6 import sip - ptr = int(sip.voidptr(buf)) - else: - ptr = buf painter.eraseRect(rect) # clear the widget canvas - qimage = QtGui.QImage(ptr, buf.shape[1], buf.shape[0], - QtGui.QImage.Format.Format_RGBA8888) - qimage.setDevicePixelRatio(self.device_pixel_ratio) - # set origin using original QT coordinates origin = QtCore.QPoint(rect.left(), rect.top()) - painter.drawImage(origin, qimage) + + for layer_name in self.figure._children_by_layer: + if layer_name in self._layer_renderers: + layer_renderer = self._layer_renderers[layer_name] + + buf = memoryview(layer_renderer.copy_from_bbox(bbox)) + + if QT_API == "PyQt6": + from PyQt6 import sip + ptr = int(sip.voidptr(buf)) + else: + ptr = buf + + qimage = QtGui.QImage(ptr, buf.shape[1], buf.shape[0], + QtGui.QImage.Format.Format_RGBA8888) + qimage.setDevicePixelRatio(self.device_pixel_ratio) + + # Qt's QPainter natively handles alpha blending! + painter.drawImage(origin, qimage) + self._draw_rect_callback(painter) finally: painter.end() diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 9920f6d908b3..2d58751ccfa5 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -65,6 +65,15 @@ def _stale_figure_callback(self, val): if (fig := self.get_figure(root=False)) is not None: + if val and hasattr(fig, '_stale_layers'): + for layer_name, artists in fig._children_by_layer.items(): + if self in artists: + fig._stale_layers[layer_name] = val + break + else: + if (self in getattr(fig, '_localaxes', []) or + self in getattr(fig, 'subfigs', [])): + fig._stale_layers["base"] = val fig.stale = val @@ -238,14 +247,8 @@ def patches(self): def texts(self): return _FigureArtistList(self, 'texts', valid_types=Text) - def _get_draw_artists(self, renderer): - """Also runs apply_aspect""" - artists = self.get_children() - - artists.remove(self.patch) - artists = sorted( - (artist for artist in artists if not artist.get_animated()), - key=lambda artist: artist.get_zorder()) + def _apply_aspects(self, renderer): + """Apply aspect ratios to all axes and children.""" for ax in self._localaxes: locator = ax.get_axes_locator() ax.apply_aspect(locator(ax, renderer) if locator else None) @@ -255,8 +258,37 @@ def _get_draw_artists(self, renderer): locator = child.get_axes_locator() child.apply_aspect( locator(child, renderer) if locator else None) + + def _get_draw_artists(self, renderer, layer): + artists = self.get_children(layer=layer) + artists = sorted( + (artist for artist in artists if not artist.get_animated()), + key=lambda artist: artist.get_zorder()) return artists + def _draw_layer(self, renderer, layer): + """ + Draw the specified layer of artists. + + Parameters + ---------- + renderer : `.RendererBase` + layer : str + The layer to draw. + """ + try: + artists = self._get_draw_artists(renderer, layer=layer) + if not artists: + return + + renderer.open_group(layer) + mimage._draw_list_compositing_images( + renderer, self, artists, self.suppressComposite) + renderer.close_group(layer) + finally: + if hasattr(self, '_stale_layers'): + self._stale_layers[layer] = False + def autofmt_xdate( self, bottom=0.2, rotation=30, ha='right', which='major'): """ @@ -302,17 +334,41 @@ def autofmt_xdate( self.subplots_adjust(bottom=bottom) self.stale = True - def get_children(self): + def get_children(self, *, layer=None): """Get a list of artists contained in the figure.""" - return [self.patch, - *self.artists, - *self._localaxes, - *self.lines, - *self.patches, - *self.texts, - *self.images, - *self.legends, - *self.subfigs] + layers = list(self._children_by_layer) if layer is None else [layer] + result = [] + + # Define the types in the exact order they should be appended + ordered_types = (Line2D, Patch, Text, mimage.FigureImage, mlegend.Legend) + + for name in layers: + children = self._children_by_layer.get(name, []) + + buckets = {cls: [] for cls in ordered_types} + artists = [] + + for a in children: + # Route the artist to its bucket, or artists + matched = next( + (cls for cls in ordered_types if isinstance(a, cls)), None + ) + if matched: + buckets[matched].append(a) + else: + artists.append(a) + + result += artists + if name == "base": + result += self._localaxes + + for cls in ordered_types: + result += buckets[cls] + + if name == "base": + result += self.subfigs + + return result def get_figure(self, root=None): """ @@ -585,7 +641,7 @@ def set_frameon(self, b): frameon = property(get_frameon, set_frameon) - def add_artist(self, artist, clip=False): + def add_artist(self, artist, clip=False, *, layer=None): """ Add an `.Artist` to the figure. @@ -601,6 +657,8 @@ def add_artist(self, artist, clip=False): ``figure.transSubfigure``. clip : bool, default: False Whether the added artist should be clipped by the figure patch. + layer : str, default: None + The layer to add the artist to. If None, the base layer is used. Returns ------- @@ -608,8 +666,13 @@ def add_artist(self, artist, clip=False): The added artist. """ artist.set_figure(self) - self._children.append(artist) - artist._remove_method = self._children.remove + resolved_layer = layer or "base" + target = self._children_by_layer.setdefault(resolved_layer, []) + target.append(artist) + artist._remove_method = target.remove + + self._stale_layers[resolved_layer] = True + artist.stale_callback = _stale_figure_callback if not artist.is_transform_set(): artist.set_transform(self.transSubfigure) @@ -1076,6 +1139,11 @@ def clear(self, keep_observers=False): self.delaxes(ax) # Remove ax from self._axstack. self._children = [] + self._children_by_layer = { + "patch": [self.patch], + "base": self._children, + } + self._stale_layers = {"patch": True, "base": True} self.subplotpars.reset() if not keep_observers: self._axobservers = cbook.CallbackRegistry() @@ -2385,6 +2453,13 @@ def __init__(self, parent, subplotspec, *, in_layout=False, transform=self.transSubfigure) self._set_artist_props(self.patch) self.patch.set_antialiased(False) + # Rebuild the dict with "patch" as the first key so that iterating + # _children_by_layer in insertion order always draws patch first. + self._children_by_layer = { + "patch": [self.patch], + "base": self._children, + } + self._stale_layers = {"patch": True, "base": True} @property def canvas(self): @@ -2494,13 +2569,13 @@ def draw(self, renderer): if not self.get_visible(): return - artists = self._get_draw_artists(renderer) - try: renderer.open_group('subfigure', gid=self.get_gid()) - self.patch.draw(renderer) - mimage._draw_list_compositing_images( - renderer, self, artists, self.get_figure(root=True).suppressComposite) + self._apply_aspects(renderer) + # Draw all layers in dict insertion order: + # patch (background) → base → any additional layers. + for _layer in self._children_by_layer: + self._draw_layer(renderer, _layer) renderer.close_group('subfigure') finally: @@ -3348,7 +3423,6 @@ def draw(self, renderer): with self._render_lock: - artists = self._get_draw_artists(renderer) try: renderer.open_group('figure', gid=self.get_gid()) if self.axes and self.get_layout_engine() is not None: @@ -3358,9 +3432,12 @@ def draw(self, renderer): pass # ValueError can occur when resizing a window. - self.patch.draw(renderer) - mimage._draw_list_compositing_images( - renderer, self, artists, self.suppressComposite) + self._apply_aspects(renderer) + + # Draw all layers in dict insertion order: + # patch (background) → base → any additional layers. + for _layer in self._children_by_layer: + self._draw_layer(renderer, _layer) renderer.close_group('figure') finally: diff --git a/lib/matplotlib/figure.pyi b/lib/matplotlib/figure.pyi index cf17f4694dbd..65b21a950cc4 100644 --- a/lib/matplotlib/figure.pyi +++ b/lib/matplotlib/figure.pyi @@ -55,7 +55,7 @@ class FigureBase(Artist): @property def texts(self) -> ArtistList[Text]: ... - def get_children(self) -> list[Artist]: ... + def get_children(self, *, layer: str | None = ...) -> list[Artist]: ... def contains(self, mouseevent: MouseEvent) -> tuple[bool, dict[Any, Any]]: ... def suptitle(self, t: str, **kwargs) -> Text: ... def get_suptitle(self) -> str: ... @@ -81,7 +81,7 @@ class FigureBase(Artist): def frameon(self) -> bool: ... @frameon.setter def frameon(self, b: bool) -> None: ... - def add_artist(self, artist: Artist, clip: bool = ...) -> Artist: ... + def add_artist(self, artist: Artist, clip: bool = ..., *, layer: str | None = ...) -> Artist: ... @overload def add_axes(self, ax: Axes) -> Axes: ... @overload diff --git a/lib/matplotlib/tests/baseline_images/test_figure/two_pass_base_only.png b/lib/matplotlib/tests/baseline_images/test_figure/two_pass_base_only.png new file mode 100644 index 000000000000..4df3c7d46950 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_figure/two_pass_base_only.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_figure/two_pass_composite.png b/lib/matplotlib/tests/baseline_images/test_figure/two_pass_composite.png new file mode 100644 index 000000000000..964a624bbddf Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_figure/two_pass_composite.png differ diff --git a/lib/matplotlib/tests/baseline_images/test_figure/two_pass_overlay_only.png b/lib/matplotlib/tests/baseline_images/test_figure/two_pass_overlay_only.png new file mode 100644 index 000000000000..1e61c6c9ad96 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_figure/two_pass_overlay_only.png differ diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index ae24effe505f..129e7d81db6a 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -185,8 +185,11 @@ def set_device_pixel_ratio(ratio): # The DPI and the renderer width/height change assert fig.dpi == dpi - assert qt_canvas.renderer.width == width - assert qt_canvas.renderer.height == height + + renderer = qt_canvas._layer_renderers["patch"] + + assert renderer.width == width + assert renderer.height == height # The actual widget size and figure logical size don't change. assert size.width() == 600 @@ -386,3 +389,40 @@ def custom_handler(signum, frame): def test_ipython(): from matplotlib.testing import ipython_in_subprocess ipython_in_subprocess("qt", {(8, 24): "qtagg", (8, 15): "QtAgg", (7, 0): "Qt5Agg"}) + + +@pytest.mark.backend('QtAgg', skip_on_importerror=True) +def test_qtagg_layer_caching(): + from matplotlib.text import Text + + fig, ax = plt.subplots() + + fig.add_artist(Text(0.5, 0.5, "patch"), layer="patch") + fig.add_artist(Text(0.5, 0.5, "base"), layer="base") + fig.add_artist(Text(0.5, 0.5, "overlay"), layer="overlay") + + with mock.patch.object( + fig, '_draw_layer', wraps=fig._draw_layer + ) as mock_draw_layer: + # 1. Initial draw: Everything is stale, so all layers must be rendered + fig.canvas.draw() + + drawn_layers = [call.args[1] for call in mock_draw_layer.call_args_list] + assert "patch" in drawn_layers + assert "base" in drawn_layers + assert "overlay" in drawn_layers + + # Reset the mock tracker for the next draw + mock_draw_layer.reset_mock() + + # 2. Second draw: ONLY make the overlay stale + fig._stale_layers["overlay"] = True + + fig.canvas.draw() + + # The caching logic should completely skip rendering 'patch' and 'base' + drawn_layers2 = [call.args[1] for call in mock_draw_layer.call_args_list] + assert "patch" not in drawn_layers2 + assert "base" not in drawn_layers2 + assert "overlay" in drawn_layers2 + assert drawn_layers2 == ["overlay"] diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index d8bf1954df94..9c11a27bd371 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -28,6 +28,7 @@ import matplotlib.patches as mpatch from matplotlib.offsetbox import AnchoredOffsetbox, TextArea import matplotlib.transforms as mtransforms +from matplotlib.text import Text @image_comparison(['figure_align_labels'], extensions=['png', 'svg'], style='mpl20', @@ -1966,3 +1967,122 @@ def test_artist_sublist_deprecations(): del fig.lines[-1] with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match): del fig.lines[1:] + + +@image_comparison( + baseline_images=['two_pass_base_only'], extensions=['png'], style='mpl20' +) +def test_two_pass_base_only(): + """Verify that bypassing the overlay pass leaves only the base layer.""" + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1], color='blue', lw=5) + + # Add overlay elements + overlay_text = Text( + 0.5, 0.5, "Overlay Text", color='red', fontsize=20, ha='center', + transform=fig.transFigure, figure=fig + ) + fig.add_artist(overlay_text, layer="overlay") + overlay_line = mlines.Line2D( + [0, 1], [1, 0], color='red', lw=5, transform=fig.transFigure + ) + fig.add_artist(overlay_line, layer="overlay") + + # Mock _draw_layer to skip the overlay layer + original_draw_layer = fig._draw_layer + def mock_draw_layer(renderer, layer): + if layer == "overlay": + return + original_draw_layer(renderer, layer) + fig._draw_layer = mock_draw_layer + + +@image_comparison( + baseline_images=['two_pass_overlay_only'], extensions=['png'], style='mpl20' +) +def test_two_pass_overlay_only(): + """ + Verify that bypassing the base pass leaves only the overlay layer (transparent). + """ + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1], color='blue', lw=5) + + # Add overlay elements + + overlay_text = Text( + 0.5, 0.5, "Overlay Text", color='red', fontsize=20, ha='center', + transform=fig.transFigure, figure=fig + ) + fig.add_artist(overlay_text, layer="overlay") + overlay_line = mlines.Line2D( + [0, 1], [1, 0], color='red', lw=5, transform=fig.transFigure + ) + fig.add_artist(overlay_line, layer="overlay") + + original_draw_layer = fig._draw_layer + def mock_draw_layer(renderer, layer): + if (layer == "base" or layer == "patch"): + return + original_draw_layer(renderer, layer) + fig._draw_layer = mock_draw_layer + + +@image_comparison( + baseline_images=['two_pass_composite'], extensions=['png'], style='mpl20' +) +def test_two_pass_composite(): + """ + Verify that the base and overlay layers compose correctly when drawn together. + """ + fig, ax = plt.subplots() + ax.plot([0, 1], [0, 1], color='blue', lw=5) + + overlay_text = Text( + 0.5, 0.5, "Overlay Text", color='red', fontsize=20, ha='center', + transform=fig.transFigure, figure=fig + ) + fig.add_artist(overlay_text, layer="overlay") + overlay_line = mlines.Line2D( + [0, 1], [1, 0], color='red', lw=5, transform=fig.transFigure + ) + fig.add_artist(overlay_line, layer="overlay") + + +def test_stale_layers_via_add_artist(): + fig = plt.figure() + overlay = Text(0.5, 0.9, "overlay") + fig.add_artist(overlay, layer="overlay") + fig.draw_without_rendering() + + assert fig._stale_layers["overlay"] is False + assert fig._stale_layers["base"] is False + assert fig._stale_layers["patch"] is False + # change only overlay + overlay.set_visible(False) + assert fig._stale_layers["overlay"] is True + assert fig._stale_layers["base"] is False + assert fig._stale_layers["patch"] is False + + +def test_stale_layers_via_text(): + fig = plt.figure() + t = fig.text(0.5, 0.5, "hello") + fig.draw_without_rendering() + + assert fig._stale_layers["base"] is False + assert fig._stale_layers["patch"] is False + t.set_color("red") + assert fig._stale_layers["base"] is True + assert fig._stale_layers["patch"] is False + + +def test_stale_layers_via_axes_text(): + fig, ax = plt.subplots() + t = ax.text(0.5, 0.5, "hello") + fig.draw_without_rendering() + + assert fig._stale_layers["base"] is False + assert fig._stale_layers["patch"] is False + t.set_color("red") + assert fig._stale_layers["base"] is True + assert fig._stale_layers["patch"] is False