Skip to content

ENH: Allow to register standalone figures with pyplot #29855

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
Open
54 changes: 54 additions & 0 deletions doc/users/next_whats_new/pyplot-register-figure.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
Figures can be attached to and removed from pyplot
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Figures can now be attached to and removed from management through pyplot, which in
the background also means a less strict coupling to backends.

In particular, standalone figures (created with the `.Figure` constructor) can now be
registered with the `.pyplot` module by calling ``plt.figure(fig)``. This allows to
show them with ``plt.show()`` as you would do with any figure created with pyplot
factory methods such as ``plt.figure()`` or ``plt.subplots()``.

When closing a shown figure window, the related figure is reset to the standalone
state, i.e. it's not visible to pyplot anymore, but if you still hold a reference
to it, you can continue to work with it (e.g. do ``fig.savefig()``, or re-add it
to pyplot with ``plt.figure(fig)`` and then show it again).

The following is now possible - though the example is exaggerated to show what's
possible. In practice, you'll stick with much simpler versions for better
consistency ::

import matplotlib.pyplot as plt
from matplotlib.figure import Figure

# Create a standalone figure
fig = Figure()
ax = fig.add_subplot()
ax.plot([1, 2, 3], [4, 5, 6])

# Register it with pyplot
plt.figure(fig)

# Modify the figure through pyplot
plt.xlabel("x label")

# Show the figure
plt.show()

# Close the figure window through the GUI

# Continue to work on the figure
fig.savefig("my_figure.png")
ax.set_ylabel("y label")

# Re-register the figure and show it again
plt.figure(fig)
plt.show()

Technical detail: Standalone figures use `.FigureCanvasBase` as canvas. This is
replaced by a backend-dependent subclass when registering with pyplot, and is
reset to `.FigureCanvasBase` when the figure is closed. `.Figure.savefig` uses
the current canvas to save the figure (if possible). Since `.FigureCanvasBase`
is Agg-based any Agg-based backend will create the same file output. There may
be slight differences for non-Agg backends; e.g. if you use "GTK4Cairo" as
interactive backend, ``fig.savefig("file.png")`` may create a slightly different
image depending on whether the figure is registered with pyplot or not.
2 changes: 2 additions & 0 deletions lib/matplotlib/_pylab_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ def destroy(cls, num):
two managers share the same number.
"""
if all(hasattr(num, attr) for attr in ["num", "destroy"]):
# num is a manager-like instance (not necessarily a
# FigureManagerBase subclass)
manager = num
if cls.figs.get(manager.num) is manager:
cls.figs.pop(manager.num)
Expand Down
4 changes: 3 additions & 1 deletion lib/matplotlib/backend_bases.py
Original file line number Diff line number Diff line change
Expand Up @@ -2723,7 +2723,9 @@ def show(self):
f"shown")

def destroy(self):
pass
# managers may have swapped the canvas to a GUI-framework specific one.
# restore the base canvas when the manager is destroyed.
self.canvas.figure._set_base_canvas()

def full_screen_toggle(self):
pass
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/_backend_gtk.py
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ def destroy(self, *args):
self._destroying = True
self.window.destroy()
self.canvas.destroy()
super().destroy()

@classmethod
def start_main_loop(cls):
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/_backend_tk.py
Original file line number Diff line number Diff line change
Expand Up @@ -606,6 +606,7 @@ def delayed_destroy():
else:
self.window.update()
delayed_destroy()
super().destroy()

def get_window_title(self):
return self.window.wm_title()
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/backend_gtk3.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ def __init__(self, figure=None):

def destroy(self):
CloseEvent("close_event", self)._process()
super().destroy()

def set_cursor(self, cursor):
# docstring inherited
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/backend_nbagg.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,7 @@ def _create_comm(self):
return comm

def destroy(self):
super().destroy()
self._send_event('close')
# need to copy comms as callbacks will modify this list
for comm in list(self.web_sockets):
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/backend_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -664,6 +664,7 @@ def show(self):
self.window.raise_()

def destroy(self, *args):
super().destroy()
# check for qApp first, as PySide deletes it in its atexit handler
if QtWidgets.QApplication.instance() is None:
return
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/backends/backend_wx.py
Original file line number Diff line number Diff line change
Expand Up @@ -1007,6 +1007,7 @@ def show(self):
def destroy(self, *args):
# docstring inherited
_log.debug("%s - destroy()", type(self))
super().destroy()
frame = self.frame
if frame: # Else, may have been already deleted, e.g. when closing.
# As this can be called from non-GUI thread from plt.close use
Expand Down
16 changes: 15 additions & 1 deletion lib/matplotlib/figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -2642,7 +2642,7 @@ def __init__(self,
self._set_artist_props(self.patch)
self.patch.set_antialiased(False)

FigureCanvasBase(self) # Set self.canvas.
self._set_base_canvas()

if subplotpars is None:
subplotpars = SubplotParams()
Expand Down Expand Up @@ -2996,6 +2996,20 @@ def get_constrained_layout_pads(self, relative=False):

return w_pad, h_pad, wspace, hspace

def _set_base_canvas(self):
"""
Initialize self.canvas with a FigureCanvasBase instance.
This is used upon initialization of the Figure, but also
to reset the canvas when decoupling from pyplot.
"""
# check if we have changed the DPI due to hi-dpi screens
orig_dpi = getattr(self, '_original_dpi', self._dpi)
FigureCanvasBase(self) # Set self.canvas as a side-effect
# put it back to what it was
if orig_dpi != self._dpi:
self.dpi = orig_dpi

def set_canvas(self, canvas):
"""
Set the canvas that contains the figure
Expand Down
23 changes: 19 additions & 4 deletions lib/matplotlib/pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -933,6 +933,10 @@ def figure(
window title is set to this value. If num is a ``SubFigure``, its
parent ``Figure`` is activated.
If *num* is a Figure instance that is already tracked in pyplot, it is
activated. If *num* is a Figure instance that is not tracked in pyplot,
it is added to the tracked figures and activated.
figsize : (float, float) or (float, float, str), default: :rc:`figure.figsize`
The figure dimensions. This can be
Expand Down Expand Up @@ -1019,21 +1023,32 @@ def figure(
in the matplotlibrc file.
"""
allnums = get_fignums()
next_num = max(allnums) + 1 if allnums else 1

if isinstance(num, FigureBase):
# type narrowed to `Figure | SubFigure` by combination of input and isinstance
has_figure_property_parameters = (
any(param is not None for param in [figsize, dpi, facecolor, edgecolor])
or not frameon or kwargs
)

root_fig = num.get_figure(root=True)
if root_fig.canvas.manager is None:
raise ValueError("The passed figure is not managed by pyplot")
elif (any(param is not None for param in [figsize, dpi, facecolor, edgecolor])
or not frameon or kwargs) and root_fig.canvas.manager.num in allnums:
if has_figure_property_parameters:
raise ValueError(
"You cannot pass figure properties when calling figure() with "
"an existing Figure instance")
backend = _get_backend_mod()
manager_ = backend.new_figure_manager_given_figure(next_num, root_fig)
_pylab_helpers.Gcf._set_new_active_manager(manager_)
return manager_.canvas.figure
elif has_figure_property_parameters and root_fig.canvas.manager.num in allnums:
_api.warn_external(
"Ignoring specified arguments in this call because figure "
f"with num: {root_fig.canvas.manager.num} already exists")
_pylab_helpers.Gcf.set_active(root_fig.canvas.manager)
return root_fig

next_num = max(allnums) + 1 if allnums else 1
fig_label = ''
if num is None:
num = next_num
Expand Down
7 changes: 3 additions & 4 deletions lib/matplotlib/tests/test_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -8223,10 +8223,9 @@ def color_boxes(fig, ax):
"""
fig.canvas.draw()

renderer = fig.canvas.get_renderer()
bbaxis = []
for nn, axx in enumerate([ax.xaxis, ax.yaxis]):
bb = axx.get_tightbbox(renderer)
bb = axx.get_tightbbox()
if bb:
axisr = mpatches.Rectangle(
(bb.x0, bb.y0), width=bb.width, height=bb.height,
Expand All @@ -8237,7 +8236,7 @@ def color_boxes(fig, ax):

bbspines = []
for nn, a in enumerate(['bottom', 'top', 'left', 'right']):
bb = ax.spines[a].get_window_extent(renderer)
bb = ax.spines[a].get_window_extent()
spiner = mpatches.Rectangle(
(bb.x0, bb.y0), width=bb.width, height=bb.height,
linewidth=0.7, edgecolor="green", facecolor="none", transform=None,
Expand All @@ -8253,7 +8252,7 @@ def color_boxes(fig, ax):
fig.add_artist(rect2)
bbax = bb

bb2 = ax.get_tightbbox(renderer)
bb2 = ax.get_tightbbox()
rect2 = mpatches.Rectangle(
(bb2.x0, bb2.y0), width=bb2.width, height=bb2.height,
linewidth=3, edgecolor="red", facecolor="none", transform=None,
Expand Down
4 changes: 4 additions & 0 deletions lib/matplotlib/tests/test_backend_qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,10 @@ def set_device_pixel_ratio(ratio):
assert qt_canvas.get_width_height() == (600, 240)
assert (fig.get_size_inches() == (5, 2)).all()

# check that closing the figure restores the original dpi
plt.close(fig)
assert fig.dpi == 120


@pytest.mark.backend('QtAgg', skip_on_importerror=True)
def test_subplottool():
Expand Down
18 changes: 12 additions & 6 deletions lib/matplotlib/tests/test_backends_interactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -220,19 +220,22 @@ def check_alt_backend(alt_backend):
fig.canvas.mpl_connect("close_event", print)

result = io.BytesIO()
fig.savefig(result, format='png')
fig.savefig(result, format='png', dpi=100)

plt.show()

# Ensure that the window is really closed.
plt.pause(0.5)

# Test that saving works after interactive window is closed, but the figure
# is not deleted.
# When the figure is closed, it's manager is removed and the canvas is reset to
# FigureCanvasBase. Saving should still be possible.
result_after = io.BytesIO()
fig.savefig(result_after, format='png')
fig.savefig(result_after, format='png', dpi=100)

assert result.getvalue() == result_after.getvalue()
if backend.endswith("agg"):
# agg-based interactive backends should save the same image as a non-interactive
# figure
assert result.getvalue() == result_after.getvalue()


@pytest.mark.parametrize("env", _get_testable_interactive_backends())
Expand Down Expand Up @@ -285,10 +288,13 @@ def _test_thread_impl():
future = ThreadPoolExecutor().submit(fig.canvas.draw)
plt.pause(0.5) # flush_events fails here on at least Tkagg (bpo-41176)
future.result() # Joins the thread; rethrows any exception.
# stash the current canvas as closing the figure will reset the canvas on
# the figure
canvas = fig.canvas
plt.close() # backend is responsible for flushing any events here
if plt.rcParams["backend"].lower().startswith("wx"):
# TODO: debug why WX needs this only on py >= 3.8
fig.canvas.flush_events()
canvas.flush_events()


_thread_safe_backends = _get_testable_interactive_backends()
Expand Down
2 changes: 0 additions & 2 deletions lib/matplotlib/tests/test_figure.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,8 +147,6 @@ def test_figure_label():
assert plt.get_figlabels() == ['', 'today']
plt.figure(fig_today)
assert plt.gcf() == fig_today
with pytest.raises(ValueError):
plt.figure(Figure())


def test_figure_label_replaced():
Expand Down
24 changes: 24 additions & 0 deletions lib/matplotlib/tests/test_pyplot.py
Original file line number Diff line number Diff line change
Expand Up @@ -471,6 +471,30 @@ def test_multiple_same_figure_calls():
assert fig is fig3


def test_register_existing_figure_with_pyplot():
from matplotlib.figure import Figure
# start with a standalone figure
fig = Figure()
assert fig.canvas.manager is None
with pytest.raises(AttributeError):
# Heads-up: This will change to returning None in the future
# See docstring for the Figure.number property
fig.number
# register the Figure with pyplot
plt.figure(fig)
assert fig.number == 1
# the figure can now be used in pyplot
plt.suptitle("my title")
assert fig.get_suptitle() == "my title"
# it also has a manager that is properly wired up in the pyplot state
assert plt._pylab_helpers.Gcf.get_fig_manager(fig.number) is fig.canvas.manager
# and we can regularly switch the pyplot state
fig2 = plt.figure()
assert fig2.number == 2
assert plt.figure(1) is fig
assert plt.gcf() is fig


def test_close_all_warning():
fig1 = plt.figure()

Expand Down
Loading
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy