Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit e31aff1

Browse files
Implement Figure-level overlay architecture with two-pass drawing
1 parent 8faa05b commit e31aff1

6 files changed

Lines changed: 183 additions & 32 deletions

File tree

lib/matplotlib/figure.py

Lines changed: 117 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,9 @@ def __init__(self, **kwargs):
209209
self._localaxes = [] # track all Axes
210210
self.subfigs = []
211211
self._children = [] # All artists except SubFigure and Axes
212+
self._children_by_layer = {"base": self._children, "overlay": []}
213+
# Note: "patch" layer is added by Figure/SubFigure.__init__ after
214+
# self.patch is created, since FigureBase has no self.patch of its own.
212215
self.stale = True
213216
self.suppressComposite = None
214217
self.set(**kwargs)
@@ -238,14 +241,8 @@ def patches(self):
238241
def texts(self):
239242
return _FigureArtistList(self, 'texts', valid_types=Text)
240243

241-
def _get_draw_artists(self, renderer):
242-
"""Also runs apply_aspect"""
243-
artists = self.get_children()
244-
245-
artists.remove(self.patch)
246-
artists = sorted(
247-
(artist for artist in artists if not artist.get_animated()),
248-
key=lambda artist: artist.get_zorder())
244+
def _apply_aspects(self, renderer):
245+
"""Apply aspect ratios to all axes and children."""
249246
for ax in self._localaxes:
250247
locator = ax.get_axes_locator()
251248
ax.apply_aspect(locator(ax, renderer) if locator else None)
@@ -255,8 +252,33 @@ def _get_draw_artists(self, renderer):
255252
locator = child.get_axes_locator()
256253
child.apply_aspect(
257254
locator(child, renderer) if locator else None)
255+
256+
def _get_draw_artists(self, renderer, layer):
257+
artists = self.get_children(layer=layer)
258+
artists = sorted(
259+
(artist for artist in artists if not artist.get_animated()),
260+
key=lambda artist: artist.get_zorder())
258261
return artists
259262

263+
def _draw_layer(self, renderer, layer):
264+
"""
265+
Draw the specified layer of artists.
266+
267+
Parameters
268+
----------
269+
renderer : `.RendererBase`
270+
layer : str
271+
The layer to draw.
272+
"""
273+
artists = self._get_draw_artists(renderer, layer=layer)
274+
if not artists:
275+
return
276+
277+
renderer.open_group(layer)
278+
mimage._draw_list_compositing_images(
279+
renderer, self, artists, self.suppressComposite)
280+
renderer.close_group(layer)
281+
260282
def autofmt_xdate(
261283
self, bottom=0.2, rotation=30, ha='right', which='major'):
262284
"""
@@ -302,17 +324,61 @@ def autofmt_xdate(
302324
self.subplots_adjust(bottom=bottom)
303325
self.stale = True
304326

305-
def get_children(self):
327+
def get_children(self, *, layer=None):
306328
"""Get a list of artists contained in the figure."""
307-
return [self.patch,
308-
*self.artists,
309-
*self._localaxes,
310-
*self.lines,
311-
*self.patches,
312-
*self.texts,
313-
*self.images,
314-
*self.legends,
315-
*self.subfigs]
329+
patch_list = self._children_by_layer.get("patch", [])
330+
331+
if layer is None:
332+
sources = [
333+
a for key, lst in self._children_by_layer.items()
334+
if key != "patch"
335+
for a in lst
336+
]
337+
elif layer == "patch":
338+
return list(patch_list)
339+
else:
340+
sources = self._children_by_layer.get(layer, [])
341+
342+
artists = []
343+
lines = []
344+
patches = []
345+
texts = []
346+
images = []
347+
legends = []
348+
349+
for a in sources:
350+
if isinstance(a, mimage.FigureImage):
351+
images.append(a)
352+
elif isinstance(a, mlegend.Legend):
353+
legends.append(a)
354+
elif isinstance(a, Line2D):
355+
lines.append(a)
356+
elif isinstance(a, Patch):
357+
patches.append(a)
358+
elif isinstance(a, Text):
359+
texts.append(a)
360+
else:
361+
artists.append(a)
362+
363+
prefix = patch_list if layer is None else []
364+
365+
if layer is None or layer == "base":
366+
return [*prefix,
367+
*artists,
368+
*self._localaxes,
369+
*lines,
370+
*patches,
371+
*texts,
372+
*images,
373+
*legends,
374+
*self.subfigs]
375+
else:
376+
return [*artists,
377+
*lines,
378+
*patches,
379+
*texts,
380+
*images,
381+
*legends]
316382

317383
def get_figure(self, root=None):
318384
"""
@@ -585,7 +651,7 @@ def set_frameon(self, b):
585651

586652
frameon = property(get_frameon, set_frameon)
587653

588-
def add_artist(self, artist, clip=False):
654+
def add_artist(self, artist, clip=False, *, layer=None):
589655
"""
590656
Add an `.Artist` to the figure.
591657
@@ -601,15 +667,18 @@ def add_artist(self, artist, clip=False):
601667
``figure.transSubfigure``.
602668
clip : bool, default: False
603669
Whether the added artist should be clipped by the figure patch.
670+
layer : str, default: None
671+
The layer to add the artist to. If None, the base layer is used.
604672
605673
Returns
606674
-------
607675
`~matplotlib.artist.Artist`
608676
The added artist.
609677
"""
610678
artist.set_figure(self)
611-
self._children.append(artist)
612-
artist._remove_method = self._children.remove
679+
target = self._children_by_layer[layer or "base"]
680+
target.append(artist)
681+
artist._remove_method = target.remove
613682

614683
if not artist.is_transform_set():
615684
artist.set_transform(self.transSubfigure)
@@ -1076,6 +1145,11 @@ def clear(self, keep_observers=False):
10761145
self.delaxes(ax) # Remove ax from self._axstack.
10771146

10781147
self._children = []
1148+
self._children_by_layer = {
1149+
"patch": [self.patch],
1150+
"base": self._children,
1151+
"overlay": [],
1152+
}
10791153
self.subplotpars.reset()
10801154
if not keep_observers:
10811155
self._axobservers = cbook.CallbackRegistry()
@@ -2385,6 +2459,9 @@ def __init__(self, parent, subplotspec, *,
23852459
in_layout=False, transform=self.transSubfigure)
23862460
self._set_artist_props(self.patch)
23872461
self.patch.set_antialiased(False)
2462+
# Now that self.patch exists, register it as its own layer so that
2463+
# _draw_layer(renderer, "patch") draws it first before "base".
2464+
self._children_by_layer["patch"] = [self.patch]
23882465

23892466
@property
23902467
def canvas(self):
@@ -2494,13 +2571,15 @@ def draw(self, renderer):
24942571
if not self.get_visible():
24952572
return
24962573

2497-
artists = self._get_draw_artists(renderer)
2498-
24992574
try:
25002575
renderer.open_group('subfigure', gid=self.get_gid())
2501-
self.patch.draw(renderer)
2502-
mimage._draw_list_compositing_images(
2503-
renderer, self, artists, self.get_figure(root=True).suppressComposite)
2576+
self._apply_aspects(renderer)
2577+
# Patch layer: always first (figure background)
2578+
self._draw_layer(renderer, "patch")
2579+
# Pass 1: base layer
2580+
self._draw_layer(renderer, "base")
2581+
# Pass 2: overlay layer
2582+
self._draw_layer(renderer, "overlay")
25042583
renderer.close_group('subfigure')
25052584

25062585
finally:
@@ -2731,6 +2810,9 @@ def __init__(self,
27312810
in_layout=False)
27322811
self._set_artist_props(self.patch)
27332812
self.patch.set_antialiased(False)
2813+
# even if this line is removed it not cause any error
2814+
# as self.clear() below rebuilds the dictionary
2815+
self._children_by_layer["patch"] = [self.patch]
27342816

27352817
self._set_base_canvas()
27362818

@@ -3348,7 +3430,6 @@ def draw(self, renderer):
33483430

33493431
with self._render_lock:
33503432

3351-
artists = self._get_draw_artists(renderer)
33523433
try:
33533434
renderer.open_group('figure', gid=self.get_gid())
33543435
if self.axes and self.get_layout_engine() is not None:
@@ -3358,9 +3439,15 @@ def draw(self, renderer):
33583439
pass
33593440
# ValueError can occur when resizing a window.
33603441

3361-
self.patch.draw(renderer)
3362-
mimage._draw_list_compositing_images(
3363-
renderer, self, artists, self.suppressComposite)
3442+
self._apply_aspects(renderer)
3443+
3444+
# Patch layer: always first (figure background)
3445+
self._draw_layer(renderer, "patch")
3446+
# Pass 1: base layer
3447+
self._draw_layer(renderer, "base")
3448+
3449+
# Pass 2: overlay layer
3450+
self._draw_layer(renderer, "overlay")
33643451

33653452
renderer.close_group('figure')
33663453
finally:

lib/matplotlib/figure.pyi

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ class FigureBase(Artist):
8181
def frameon(self) -> bool: ...
8282
@frameon.setter
8383
def frameon(self, b: bool) -> None: ...
84-
def add_artist(self, artist: Artist, clip: bool = ...) -> Artist: ...
84+
def add_artist(self, artist: Artist, clip: bool = ..., *, layer: str | None = ...) -> Artist: ...
8585
@overload
8686
def add_axes(self, ax: Axes) -> Axes: ...
8787
@overload

lib/matplotlib/testing/compare.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -401,10 +401,13 @@ def calculate_rms(expected_image, actual_image):
401401

402402
def _load_image(path):
403403
img = Image.open(path)
404+
# Always convert to RGBA first to ensure consistent handling of
405+
# palette/transparency images, avoiding Pillow conversion warnings.
406+
img = img.convert("RGBA")
404407
# In an RGBA image, if the smallest value in the alpha channel is 255, all
405408
# values in it must be 255, meaning that the image is opaque. If so,
406409
# discard the alpha channel so that it may compare equal to an RGB image.
407-
if img.mode != "RGBA" or img.getextrema()[3][0] == 255:
410+
if img.getextrema()[3][0] == 255:
408411
img = img.convert("RGB")
409412
return np.asarray(img)
410413

13.1 KB
Loading
3.6 KB
Loading

lib/matplotlib/tests/test_figure.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1966,3 +1966,64 @@ def test_artist_sublist_deprecations():
19661966
del fig.lines[-1]
19671967
with pytest.warns(mpl.MatplotlibDeprecationWarning, match=match):
19681968
del fig.lines[1:]
1969+
1970+
1971+
@image_comparison(
1972+
baseline_images=['two_pass_base_only'], extensions=['png'], style='mpl20'
1973+
)
1974+
def test_two_pass_base_only():
1975+
"""Verify that bypassing the overlay pass leaves only the base layer."""
1976+
fig, ax = plt.subplots()
1977+
ax.plot([0, 1], [0, 1], color='blue', lw=5)
1978+
1979+
# Add overlay elements
1980+
from matplotlib.text import Text
1981+
overlay_text = Text(
1982+
0.5, 0.5, "Overlay Text", color='red', fontsize=20, ha='center',
1983+
transform=fig.transFigure, figure=fig
1984+
)
1985+
fig.add_artist(overlay_text, layer="overlay")
1986+
import matplotlib.lines as mlines
1987+
overlay_line = mlines.Line2D(
1988+
[0, 1], [1, 0], color='red', lw=5, transform=fig.transFigure
1989+
)
1990+
fig.add_artist(overlay_line, layer="overlay")
1991+
1992+
# Mock _draw_layer to skip the overlay layer
1993+
original_draw_layer = fig._draw_layer
1994+
def mock_draw_layer(renderer, layer):
1995+
if layer == "overlay":
1996+
return
1997+
original_draw_layer(renderer, layer)
1998+
fig._draw_layer = mock_draw_layer
1999+
2000+
2001+
@image_comparison(
2002+
baseline_images=['two_pass_overlay_only'], extensions=['png'], style='mpl20'
2003+
)
2004+
def test_two_pass_overlay_only():
2005+
"""
2006+
Verify that bypassing the base pass leaves only the overlay layer (transparent).
2007+
"""
2008+
fig, ax = plt.subplots()
2009+
ax.plot([0, 1], [0, 1], color='blue', lw=5)
2010+
2011+
# Add overlay elements
2012+
from matplotlib.text import Text
2013+
overlay_text = Text(
2014+
0.5, 0.5, "Overlay Text", color='red', fontsize=20, ha='center',
2015+
transform=fig.transFigure, figure=fig
2016+
)
2017+
fig.add_artist(overlay_text, layer="overlay")
2018+
import matplotlib.lines as mlines
2019+
overlay_line = mlines.Line2D(
2020+
[0, 1], [1, 0], color='red', lw=5, transform=fig.transFigure
2021+
)
2022+
fig.add_artist(overlay_line, layer="overlay")
2023+
2024+
original_draw_layer = fig._draw_layer
2025+
def mock_draw_layer(renderer, layer):
2026+
if (layer == "base" or layer == "patch"):
2027+
return
2028+
original_draw_layer(renderer, layer)
2029+
fig._draw_layer = mock_draw_layer

0 commit comments

Comments
 (0)