diff --git a/doc/users/next_whats_new/pyplot-register-figure.rst b/doc/users/next_whats_new/pyplot-register-figure.rst new file mode 100644 index 000000000000..1acc0d0bf767 --- /dev/null +++ b/doc/users/next_whats_new/pyplot-register-figure.rst @@ -0,0 +1,58 @@ +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. In +general, you should not store a reference to the canvas, but rather always +obtain it from the figure with ``fig.canvas``. This will return the current +canvas, which is either the original `.FigureCanvasBase` or a backend-dependent +subclass, depending on whether the figure is registered with pyplot or not. diff --git a/lib/matplotlib/_pylab_helpers.py b/lib/matplotlib/_pylab_helpers.py index e3c3d98cb156..05f6d8aa02b3 100644 --- a/lib/matplotlib/_pylab_helpers.py +++ b/lib/matplotlib/_pylab_helpers.py @@ -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) diff --git a/lib/matplotlib/backend_bases.py b/lib/matplotlib/backend_bases.py index 4adaecb7f8c0..51db8dc054e5 100644 --- a/lib/matplotlib/backend_bases.py +++ b/lib/matplotlib/backend_bases.py @@ -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 diff --git a/lib/matplotlib/backends/_backend_gtk.py b/lib/matplotlib/backends/_backend_gtk.py index ac443730e28a..ce6982a72526 100644 --- a/lib/matplotlib/backends/_backend_gtk.py +++ b/lib/matplotlib/backends/_backend_gtk.py @@ -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): diff --git a/lib/matplotlib/backends/_backend_tk.py b/lib/matplotlib/backends/_backend_tk.py index eaf868fd8bec..3cd349cb9e17 100644 --- a/lib/matplotlib/backends/_backend_tk.py +++ b/lib/matplotlib/backends/_backend_tk.py @@ -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() diff --git a/lib/matplotlib/backends/backend_gtk3.py b/lib/matplotlib/backends/backend_gtk3.py index 888f5a770f5d..68ba3a329b5e 100644 --- a/lib/matplotlib/backends/backend_gtk3.py +++ b/lib/matplotlib/backends/backend_gtk3.py @@ -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 diff --git a/lib/matplotlib/backends/backend_nbagg.py b/lib/matplotlib/backends/backend_nbagg.py index 4d18e1e9fb88..543454ab25fd 100644 --- a/lib/matplotlib/backends/backend_nbagg.py +++ b/lib/matplotlib/backends/backend_nbagg.py @@ -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): diff --git a/lib/matplotlib/backends/backend_qt.py b/lib/matplotlib/backends/backend_qt.py index 401ce0b0b754..68d89e1990bb 100644 --- a/lib/matplotlib/backends/backend_qt.py +++ b/lib/matplotlib/backends/backend_qt.py @@ -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 diff --git a/lib/matplotlib/backends/backend_wx.py b/lib/matplotlib/backends/backend_wx.py index f83a69d8361e..5219042e7971 100644 --- a/lib/matplotlib/backends/backend_wx.py +++ b/lib/matplotlib/backends/backend_wx.py @@ -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 diff --git a/lib/matplotlib/figure.py b/lib/matplotlib/figure.py index 03549dd53bc1..eba873cdc221 100644 --- a/lib/matplotlib/figure.py +++ b/lib/matplotlib/figure.py @@ -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() @@ -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 diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index e916d57f8871..e6c609c2b84a 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -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 @@ -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 diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index c96173e340f7..e0b651095cb5 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -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, @@ -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, @@ -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, diff --git a/lib/matplotlib/tests/test_backend_qt.py b/lib/matplotlib/tests/test_backend_qt.py index 6e147fd14380..5bb81e5c1e2d 100644 --- a/lib/matplotlib/tests/test_backend_qt.py +++ b/lib/matplotlib/tests/test_backend_qt.py @@ -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(): diff --git a/lib/matplotlib/tests/test_backends_interactive.py b/lib/matplotlib/tests/test_backends_interactive.py index 9f8522a9df4a..671ad8466aee 100644 --- a/lib/matplotlib/tests/test_backends_interactive.py +++ b/lib/matplotlib/tests/test_backends_interactive.py @@ -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()) @@ -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() diff --git a/lib/matplotlib/tests/test_figure.py b/lib/matplotlib/tests/test_figure.py index c5890a2963b3..5f0e68648966 100644 --- a/lib/matplotlib/tests/test_figure.py +++ b/lib/matplotlib/tests/test_figure.py @@ -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(): diff --git a/lib/matplotlib/tests/test_pyplot.py b/lib/matplotlib/tests/test_pyplot.py index 55f7c33cb52e..44555a333a8c 100644 --- a/lib/matplotlib/tests/test_pyplot.py +++ b/lib/matplotlib/tests/test_pyplot.py @@ -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() 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