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

Skip to content

Conversation

timhoffm
Copy link
Member

@timhoffm timhoffm commented Apr 1, 2025

It may be fundamentally nice not to have to create the figure though pyplot to be able to use it in pyplot afterwards. You can now do

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

fig = Figure()
fig.subplots().plot([1, 3, 2])

plt.figure(fig)  # fig is now tracked in pyplot
plt.show()

This also opens up the possibility to more dynamically track and untrack figures in pyplot, which opens up the road to optimized figure tracking in pyplot (#29849)

Anybody, feel free to play around with this and try to break it.

@rcomer
Copy link
Member

rcomer commented Apr 2, 2025

I was hoping I could modify the figure and show again, but that does not seem to be the case

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

fig = Figure()
ax = fig.subplots()
ax.plot([0, 2])
plt.figure(fig)
plt.show()

ax.set_title('A cool line')
plt.figure(fig)
plt.show()

No title is shown 😕

@timhoffm timhoffm marked this pull request as draft April 2, 2025 11:34
@anntzer
Copy link
Contributor

anntzer commented May 1, 2025

This would also close #19956.

timhoffm added 2 commits July 27, 2025 22:20
It may be fundamentally nice not to have to create the figure
though pyplot to be able to use it in pyplot afterwards. You can now do

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

fig = Figure()
fig.subplots().plot([1, 3, 2])

plt.figure(fig)  # fig is now tracked in pyplot
plt.show()
```

This also opens up the possibility to more dynamically track
and untrack figures in pyplot, which opens up the road to
optimized figure tracking in pyplot (matplotlib#29849)
@timhoffm
Copy link
Member Author

Showing again now works properly. When destroying a figure manager, the figure's canvas is reset to a FigureCanvasBase.

Technical questions:

  • I've added resetting the canvas to the specific manager's destroy method (currently only implemented for Qt). This means, I have to add it to all gui-specific managers. But I feel it belongs there: The gui-specific managers replace the FigureCanvasBase when they are created, so they should undo this when they are destroyed. - One could in theory add it to FigureManagerBase.destroy(). However, that is currently empty and not called from any of the specific FigureManagerX.destroy(), so we'd need to inject super().destroy()` calls and then I think I'd rather keep it explicit. - Feedback welcome.
  • I've also not a good idea how to test this, i.e. the equivalent of ENH: Allow to register standalone figures with pyplot #29855 (comment). I suspect, I need to use a proper GUI backend with a real window and then somehow get the window to close. - Can this be done in tests? - After that, I could check that the fig.canvas is a FigureCanvasBase again.

@timhoffm
Copy link
Member Author

timhoffm commented Jul 27, 2025

Note: the failing tests are in test_interactive_backend, which saves the figure to file, closes it, and then saves it to file again, expecting exactly the same output.

result = io.BytesIO()
fig.savefig(result, format='png')
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.
result_after = io.BytesIO()
fig.savefig(result_after, format='png')
assert result.getvalue() == result_after.getvalue()

This is now no longer the case, as closing the figure resets the canvas to FigureCanvasBase, i.e. the images are genereated through different canvases - FigureCanvasBase and the backend-specific canvas, which do not necessarily have pixel-identical output. An example diff looks like this:

after-failed-diff

How should we handle this? I'm inclined to say that the previous expectation is no longer justified, and it makes sense that the canvas is reset. Should we try to fix this with tolerances? Or don't we need this image test at all anymore and instead it is sufficient to test that the figure has again a FigureCanvasBase (which imlies it can be saved with savefig, but we don't have to test the contents? Or are there other approaches?

@anntzer
Copy link
Contributor

anntzer commented Jul 27, 2025

It's a bit strange that the end png result is not the same, as both should ultimately go through Agg for rasterizing... It would be nice to figure out what is going wrong, but I agree this isn't a blocker.

@timhoffm
Copy link
Member Author

timhoffm commented Jul 27, 2025

Failing tests are

FAILED lib/matplotlib/tests/test_backends_interactive.py::test_interactive_backend[toolbar2-MPLBACKEND=qtagg-QT_API=PyQt5-BACKEND_DEPS=PyQt5]
FAILED lib/matplotlib/tests/test_backends_interactive.py::test_interactive_backend[toolbar2-MPLBACKEND=qtcairo-QT_API=PyQt6-BACKEND_DEPS=PyQt6,cairocffi]
FAILED lib/matplotlib/tests/test_backends_interactive.py::test_interactive_backend[toolbar2-MPLBACKEND=qtcairo-QT_API=PySide6-BACKEND_DEPS=PySide6,cairocffi]
FAILED lib/matplotlib/tests/test_backends_interactive.py::test_interactive_backend[toolbar2-MPLBACKEND=qtcairo-QT_API=PyQt5-BACKEND_DEPS=PyQt5,cairocffi]
FAILED lib/matplotlib/tests/test_backends_interactive.py::test_interactive_backend[toolbar2-MPLBACKEND=qtcairo-QT_API=PySide2-BACKEND_DEPS=PySide2,cairocffi]

and the same again for toolmanager, which I left out for simplicity. So all cairocffi tests fail and addtionally the qtagg-PyQt5 test. But the agg-based tests for PyQt6, PySide2 and PySide6 pass. Interestingly, the qtagg-PyQt5 test passes on my local machine.

The above diff image was from qtcairo-PyQt5.

@anntzer
Copy link
Contributor

anntzer commented Jul 28, 2025

At least for the cairo tests this is understandable: if you reset to FigureCanvasBase, then they will now rasterize using agg, whereas they rasterized with cairo prior to that. I guess that's probably fine?
No idea about the qt5agg test, though.

@anntzer
Copy link
Contributor

anntzer commented Jul 29, 2025

  • I've added resetting the canvas to the specific manager's destroy method (currently only implemented for Qt). This means, I have to add it to all gui-specific managers. But I feel it belongs there: The gui-specific managers replace the FigureCanvasBase when they are created, so they should undo this when they are destroyed. - One could in theory add it to FigureManagerBase.destroy(). However, that is currently empty and not called from any of the specific FigureManagerX.destroy(), so we'd need to inject super().destroy()` calls and then I think I'd rather keep it explicit. - Feedback welcome.

I prefer adding doing this via super() calls: for the benefit of third-parties, it is relatively easier to document "one needs to call super().destroy() in subclasses" rather than "one needs to do this and that" (restore the default canvas, but who knows what else can change in the future).

@timhoffm
Copy link
Member Author

My thought was that the specific FigureManager* classes override the canvas in their __init__ and hence they should undo what they did in their destroy(). But one could also argue that resetting to the base canvas is a universal property of destroying any manager.

When destroying a manager, replace the figure's canvas by a
figure canvas base.
There in now machinery for all of the public API that takes a
renderer is input to get it from the current canvas on the root
figure.  Use that machinery instead.
@tacaswell
Copy link
Member

I took the liberty of fixing one of the tests. We probably should purge all of the canvas.get_renderer() calls from the tests, but that is a later problem.

@tacaswell
Copy link
Member

"power-cycled" to re-trigger pinning away from the known-bad pytest-rerunner.

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.
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe add a comment that there are currently known limitations wrt. blitting/widgets (#30503)? We can always edit that out once that issue is fixed.

Copy link
Member Author

Choose a reason for hiding this comment

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

Added

Additionally, the swapping of the canvas currently does not play well with
blitting of matplotlib widgets; in such cases either deactivate blitting or do not
continue to use the figure (e.g. saving it after closing the window).

Copy link
Contributor

@anntzer anntzer left a comment

Choose a reason for hiding this comment

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

Modulo addressing the comments.

@timhoffm timhoffm force-pushed the pyplot-register-figure branch from 6ac7b60 to 3cb0681 Compare September 7, 2025 07:44
@timhoffm timhoffm force-pushed the pyplot-register-figure branch 3 times, most recently from d420613 to 6dcd489 Compare September 8, 2025 19:48
import must be local
@timhoffm timhoffm force-pushed the pyplot-register-figure branch from 6dcd489 to 6b1797b Compare September 8, 2025 19:49
src/_macosx.m Outdated
@@ -686,6 +686,7 @@ bool mpl_check_modifier(bool present, PyObject* list, char const* name)
{
[self->window close];
self->window = NULL;
[super destroy];
Copy link
Member Author

Choose a reason for hiding this comment

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

I could need a bit of help here from someone with more Objective-C / Cpython knowledge. This is missing the equivalent of super.destroy(). The change here was a rather wild guess from pattern-matching other similar cases in the module, but is apparently not correct, because this now fails the build with

../../src/_macosx.m:689:6: error: use of undeclared identifier 'super'

Copy link
Contributor

@greglucas greglucas Sep 8, 2025

Choose a reason for hiding this comment

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

Do you need to go into the objective C layer or can you stay at the Python layer (have not looked at the updates here in detail). But, we do have some multiple inheritance going on here, so you may have to explicitly call which super you want to interact with at this place. (I think the super().destroy() currently on these lines goes to the objective C implementation, and you want to go the FigureManagerBase implementation [we want to travel to both it sounds like])

def destroy(self):
# We need to clear any pending timers that never fired, otherwise
# we get a memory leak from the timer callbacks holding a reference
while self.canvas._timers:
timer = self.canvas._timers.pop()
timer.stop()
super().destroy()

Copy link
Member Author

Choose a reason for hiding this comment

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

super() goes to the next class in MRO, and that covers the whole inheritance tree also in case of multiple inheritance if all classes in the hierarchy call it without explicitly stating the parent. That‘s what I want. I do not want to mess with calling any specific parent.

Copy link
Contributor

Choose a reason for hiding this comment

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

My quick suggestion would be to avoid trying to call super() from the C-layer. To avoid the multiple inheritance issue, you can rename the (PyCFunction)FigureManager_destroy to (PyCFunction)FigureManager__destroy or something else (its in a private module) and then call that method explicitly from the Python destroy() side. You can keep the super().destroy() already there which will still travel the tree as you want I think.

Copy link
Contributor

Choose a reason for hiding this comment

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

@timhoffm timhoffm force-pushed the pyplot-register-figure branch 2 times, most recently from a3e97f8 to 232e562 Compare September 9, 2025 20:59
@timhoffm timhoffm force-pushed the pyplot-register-figure branch from 232e562 to 774d02c Compare September 9, 2025 21:30
src/_macosx.m Outdated
Comment on lines 692 to 707
// call super().destroy()
PyObject *super_obj = PyObject_CallFunctionObjArgs(
(PyObject *)&PySuper_Type,
(PyObject *)&FigureManagerType,
self,
NULL
);
if (super_obj == NULL) {
return NULL; // error
}
PyObject *result = PyObject_CallMethod(super_obj, "destroy", NULL);
Py_DECREF(super_obj);
if (result == NULL) {
return NULL; // error
}
Py_DECREF(result);
Copy link
Member Author

Choose a reason for hiding this comment

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

There are two approvals already, but since this super() call is new and I'm not the most experienced C-API developer, I'd like someone to cross check this bit again.

@timhoffm timhoffm force-pushed the pyplot-register-figure branch from 6f596db to ccf1994 Compare September 11, 2025 09:10
Copy link
Member

@QuLogic QuLogic left a comment

Choose a reason for hiding this comment

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

LGTM, but maybe someone on macOS should double-check that part.

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.

6 participants