Skip to content

[Bug]: AttributeError: 'NoneType' object has no attribute 'dpi' after drawing and removing contours inside artist #21569

Description

@astrofrog

Bug summary

I am running into issues with removing contour lines with Matplotlib 3.5.0rc1 - this worked with previous stable versions of Matplotlib, so it is a regression. I think this started failing with #19623.

The bug is triggered when contour lines are added and removed inside an artist. The example code below may appear a little convoluted, but it represents how the contours are drawn in WCSAxes, and the error happens the second time the figure is saved (once the contours have been removed and re-added).

Using objgraph reveals that after the contour lines are removed from the plot, there are residual references to the original PathCollection objects which are causing them to be drawn, but since .figure is no longer set on them, the code crashes.

Code for reproduction

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.artist import Artist


class WCSAxesArtist(Artist):
    def draw(self, renderer, *args, **kwargs):
        if hasattr(self, '_cset') and self._cset:
            for line in self._cset.collections:
                line.remove()
        self._cset = self.axes.contour(np.arange(100).reshape(10, 10), levels=[50])


fig = plt.figure()
ax = fig.add_subplot(1, 1, 1)
artist = WCSAxesArtist()
ax.add_artist(artist)
plt.savefig('test.png')
plt.savefig('test.png')

Actual outcome

An error occurs the second time the figure is saved:

Traceback (most recent call last):
  File "/Users/tom/Dropbox/Code/Astropy/astropy/debug.py", line 19, in <module>
    plt.savefig('test.png')
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/pyplot.py", line 958, in savefig
    res = fig.savefig(*args, **kwargs)
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/figure.py", line 3012, in savefig
    self.canvas.print_figure(fname, **kwargs)
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/backend_bases.py", line 2309, in print_figure
    result = print_method(
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/backend_bases.py", line 1643, in wrapper
    return func(*args, **kwargs)
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/_api/deprecation.py", line 412, in wrapper
    return func(*inner_args, **inner_kwargs)
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/backends/backend_agg.py", line 538, in print_png
    FigureCanvasAgg.draw(self)
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/backends/backend_agg.py", line 434, in draw
    self.figure.draw(self.renderer)
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/artist.py", line 73, in draw_wrapper
    result = draw(artist, renderer, *args, **kwargs)
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/artist.py", line 50, in draw_wrapper
    return draw(artist, renderer)
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/figure.py", line 2803, in draw
    mimage._draw_list_compositing_images(
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/image.py", line 132, in _draw_list_compositing_images
    a.draw(renderer)
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/artist.py", line 50, in draw_wrapper
    return draw(artist, renderer)
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/axes/_base.py", line 3082, in draw
    mimage._draw_list_compositing_images(
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/image.py", line 132, in _draw_list_compositing_images
    a.draw(renderer)
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/artist.py", line 50, in draw_wrapper
    return draw(artist, renderer)
  File "/Users/tom/python/dev/lib/python3.9/site-packages/matplotlib/collections.py", line 976, in draw
    self.set_sizes(self._sizes, self.figure.dpi)
AttributeError: 'NoneType' object has no attribute 'dpi'

Expected outcome

No console output

Operating system

MacOS X

Matplotlib Version

3.5.0rc1

Matplotlib Backend

Agg

Python version

3.9.7

Jupyter version

No response

Other libraries

No response

Installation

pip

Conda channel

No response

Activity

  1. changed the title [-][Bug]: AttributeError: 'NoneType' object has no attribute 'dpi' after removing contours[/-] [+][Bug]: AttributeError: 'NoneType' object has no attribute 'dpi' after drawing and removing contours inside artist[/+] on Nov 8, 2021
  2. QuLogic commented on Nov 9, 2021

    @QuLogic
    Member

    Changing what's in the artist list to draw while drawing seems like a bad idea. There will have been a copy of the list made, sorted for zorder, removing non-visible participants, etc. It might even have been drawn already before you called .remove.

  3. jklymak commented on Nov 9, 2021

    @jklymak
    Member

    I agree w/ @QuLogic - its not clear what you are trying to accomplish here, but the following is how one would do this normally:

    import numpy as np
    import matplotlib.pyplot as plt
    from matplotlib.artist import Artist
    
    class WCSAxesArtist(Artist):
        pass
    
    fig = plt.figure()
    ax = fig.add_subplot(1, 1, 1)
    artist = WCSAxesArtist()
    ax.add_artist(artist)
    artist._cset = artist.axes.contour(np.arange(100).reshape(10, 10), levels=10)
    plt.savefig('test.png')
    for line in artist._cset.collections:
        line.remove()
    artist._cset = artist.axes.contour(np.arange(100).reshape(10, 10), levels=10)
    plt.savefig('test.png')
    

    and that works as expected. I'm not quite sure how to get what you are after, but I don't think its a good idea to have draw adding and deleting artists from the axes.

  4. astrofrog commented on Nov 9, 2021

    @astrofrog
    ContributorAuthor

    @jklymak - I agree it is a little strange - and rather than give a long explanation of how we ended up doing this in astropy.visualization, I'll try and investigate other/cleaner ways of doing this (the short version is that we are using contours to draw grid lines and we compute this and ticks, tick labels etc at draw time and we wrap everything up in a single artist).

    I just wanted to make sure this wasn't a use case that should work and was bringing up an underlying memory management issue in Matplotlib.

  5. greglucas commented on Nov 9, 2021

    @greglucas
    Contributor

    Also note, this example failed with filled contours in v3.4.3, so the linked PR brought the return class from contour into line with contourf.

  6. jklymak commented on Nov 9, 2021

    @jklymak
    Member

    I guess the nicest thing to do would be to make that an axes subclass and then it will get drawn with other axes, rather than inside a parent axes list.

    It would be interesting to understand why this broke if it worked before.

  7. greglucas commented on Nov 9, 2021

    @greglucas
    Contributor

    It is because we have changed the Collection type, and the new PathCollection intercepts draw and uses the self.figure.dpi property, whereas the LineCollection doesn't reference the figure at all in its draw() routine.

    def draw(self, renderer):
    self.set_sizes(self._sizes, self.figure.dpi)
    super().draw(renderer)

    A quick "fix" to make this use-case work could be adding in a getattr(self.figure, "dpi", 72) to pass a default value into set_sizes, but that does not seem ideal and would lead to whack-a-mole for finding these broken figure attributes in other areas as well.

  8. jklymak commented on Nov 9, 2021

    @jklymak
    Member

    Everytime I see a reference to dpi this high up in the draw tree, I get suspicious that we are doing something wrong. Only the backends should need to know what their dpi is.

    Not sure that fixing this would fix the problem though. For some reason there are objects kicking around that have been orphaned from their figure, but still exist in a list of valid artists. That just seems ripe for other bad issues. I assume it all gets cleaned up by the next draw?

  9. tacaswell commented on Nov 11, 2021

    @tacaswell
    Member

    Luckily Axes.contour is very thin:

    @_preprocess_data()
    @docstring.dedent_interpd
    def contour(self, *args, **kwargs):
    """
    Plot contour lines.
    Call signature::
    contour([X, Y,] Z, [levels], **kwargs)
    %(contour_doc)s
    """
    kwargs['filled'] = False
    contours = mcontour.QuadContourSet(self, *args, **kwargs)
    self._request_autoscale_view()
    return contours

    On the downside, QuadContourSet adds itself to the axes on init which makes it impossible to do something like

    def draw(self, renderer):
         art = ContourArtist(...)
         art.set_axes(self.axes)   # tell the artist about the Axes, but not the other way around! 
         art.draw(renderer)

    The call to set_sizes is being used to update a the transform array (which is a Nx3x3 array, not a Tranform object) because for most sized collections the "user facing" unit is "square pixels" (and the self._factor scales for different shapes)

    def set_sizes(self, sizes, dpi=72.0):
    """
    Set the sizes of each member of the collection.
    Parameters
    ----------
    sizes : ndarray or None
    The size to set for each element of the collection. The
    value is the 'area' of the element.
    dpi : float, default: 72
    The dpi of the canvas.
    """
    if sizes is None:
    self._sizes = np.array([])
    self._transforms = np.empty((0, 3, 3))
    else:
    self._sizes = np.asarray(sizes)
    self._transforms = np.zeros((len(self._sizes), 3, 3))
    scale = np.sqrt(self._sizes) * dpi / 72.0 * self._factor
    self._transforms[:, 0, 0] = scale
    self._transforms[:, 1, 1] = scale
    self._transforms[:, 2, 2] = 1.0
    self.stale = True

    Unfortunately, dpi is not something that we expect on the base renderer (there is a RendererBase.points_to_pixels, but I think that will give use different results for at least svg), but we do know that as part of the save we set the dpi on the figure (so the dpi transform is right).


    I wonder if previously we were always drawing the "old" contours or if this is also fallout from unifying Artist storage into one list?

  10. added this to the v3.5.0 milestone on Nov 11, 2021
  11. tacaswell commented on Nov 12, 2021

    @tacaswell
    Member

    @astrofrog At this point we are not planning to hold 3.5.0 over this as you have a work-around on the astropy side and I'm not sure that this ever actually did what you wanted it to do...

  12. astrofrog commented on Nov 12, 2021

    @astrofrog
    ContributorAuthor

    Yes feel free to even close this if you like unless there are some underlying issues you think need fixing. Thanks everyone for your replies!

  13. tacaswell commented on Nov 12, 2021

    @tacaswell
    Member

    OK, I'm going to close this as while I think there is a bunch of things that we could do to work around this, none of them are quick and we have other reasons for wanting to do them.

    Long term, this sort of thing (a contour plot that updates its levels as a function of view limits based on a fixed input dataset) is the sort of thing that should be possible (and maybe even easy) after the work we are planning to do with the ROSES grant.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions