8000 [Bug]: Rendering inconsistency between matplotlib-inline `plt.show()` and `fig.savefig(...)` · Issue #24281 · matplotlib/matplotlib · GitHub
[go: up one dir, main page]

Skip to content
8000

[Bug]: Rendering inconsistency between matplotlib-inline plt.show() and fig.savefig(...) #24281

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

Closed
posita opened this issue Oct 26, 2022 · 9 comments
Labels
status: needs clarification Issues that need more information to resolve.

Comments

@posita
Copy link
posita commented Oct 26, 2022

Bug summary

This originally came up in jupyterlite/jupyterlite#850, but I'm pretty sure the root cause is here. I'll reproduce the most helpful comment from that thread here. The following is a pretty good demonstration of how savefig clips (e.g.) the title of a pie chart.

Possibly related issues:

Code for reproduction

Run the following in Jupyter Lab to reproduce the attached images.

import matplotlib.pyplot
import base64, io
from enum import Enum
from ipywidgets import widgets
from fractions import Fraction
from itertools import chain

data = {3: Fraction(1, 216), 4: Fraction(1, 72), 5: Fraction(1, 36), 6: Fraction(5, 108), 7: Fraction(5, 72), 8: Fraction(7, 72), 9: Fraction(25, 216), 10: Fraction(1, 8), 11: Fraction(1, 8), 12: Fraction(25, 216), 13: Fraction(7, 72), 14: Fraction(5, 72), 15: Fraction(5, 108), 16: Fraction(1, 36), 17: Fraction(1, 72), 18: Fraction(1, 216)}

class Format(str, Enum):
    NATIVE = "native"
    PNG = "png"
    SVG = "svg"

class PngImage:
    def __init__(self, data):
        self.data = data

    def _repr_png_(self):
        return base64.b64encode(self.data).decode()

class SvgImage:
    def __init__(self, data):
        self.data = data

    def _repr_svg_(self):
        return self.data.decode()

@widgets.interact
def show(output_to_format = Format, use_subplots: bool = True):
    with matplotlib.style.context("bmh"):
        if use_subplots:
            _, ax = matplotlib.pyplot.subplots()
        else:
            fig = matplotlib.pyplot.figure()
            xmin, ymin, dx, dy = 0.1, 0.1, 0.9, 0.9
            ax = fig.add_axes((xmin, ymin, dx, dy))
        unique_outcomes = sorted(data.keys())
        ax.set_title(
            "Super duper duper long title that will probably exceed the width of the pie chart",
            fontdict={"fontweight": "bold",},
        )
        outcomes, values = zip(*((outcome, float(value)) for outcome, value in data.items()))
        ax.pie(outcomes, values)
        if output_to_format is Format.NATIVE:
            matplotlib.pyplot.show()
        else:
            buf = io.BytesIO()
            matplotlib.pyplot.savefig(buf, format=output_to_format)
            if output_to_format is Format.PNG:
                display(PngImage(buf.getvalue()))
            elif output_to_format is Format.SVG:
                display(SvgImage(buf.getvalue()))
            else:
                assert False, "should never be here"
            matplotlib.pyplot.clf()

print(matplotlib.__version__)

Actual outcome

Output method Using subplots Using add_axes
show() native-subplots native-manual-axes
savefig(..., format="png") png-subplots png-manual-axes
savefig(..., format="svg") svg-subplots svg-manual-axes

Expected outcome

I expected all backends to produce at least similar results (i.e., not ones where titles were clipped or missing entirely).

Additional information

No response

Operating system

No response

Matplotlib Version

3.5.2, 3.6.1

Matplotlib Backend

module://matplotlib_inline.backend_inline

Python version

3.10.6

Jupyter version

Lab 3.4.8

Installation

No response

@jklymak
Copy link
Member
jklymak commented Oct 26, 2022

It looks like the widget is doing the equivalent of matplotlib.pyplot.savefig(buf, format=output_to_format, bbox_inches='tight') which will clip the figure to whatever artists are in it. That is the choice the inline backend makes as well. I don't think this is a bug, or actionable on our part.

@jklymak jklymak added the status: needs clarification Issues that need more information to resolve. label Oct 26, 2022
@posita
Copy link
Author
posita commented Oct 26, 2022

It looks like the widget is doing the equivalent of matplotlib.pyplot.savefig(buf, format=output_to_format, bbox_inches='tight') which will clip the figure to whatever artists are in it. That is the choice the inline backend makes as well. I don't think this is a bug, or actionable on our part.

Why would that yield a different result from, say matplotlib.pyplot.tight_layout() ; matplotlib.pyplot.show()?

@posita
Copy link
Author
posita commented Oct 26, 2022

@jklymak, how would one go about getting similar results for both matplotlib.pyplot.show() and matplotlib.pyplot.savefig(...) then? Or are you saying this is an ipywidgets issue and should be filed against that project?

@jklymak
Copy link
Member
jklymak commented Oct 26, 2022

It looks like the widget is doing the equivalent of matplotlib.pyplot.savefig(buf, format=output_to_format, bbox_inches='tight') which will clip the figure to whatever artists are in it. That is the choice the inline backend makes as well. I don't think this is a bug, or actionable on our part.

Why would that yield a different result from, say matplotlib.pyplot.tight_layout() ; matplotlib.pyplot.show()?

Because bbox_inches='tight' clips the figure to the artists as draw, plt.tight_layout adjusts the size of the axes so the artists fit in the figure, but doesn't change the size of the figure.

@jklymak, how would one go about getting similar results for both matplotlib.pyplot.show() and matplotlib.pyplot.savefig(...) then?

To get similar results, you pass bbox_inches='tight' to savefig. I think there is also a configuration option to change ipython to not do bbox_inches='tight', but forget what it is.

@tacaswell tacaswell changed the title [Bug]: Rendering inconsistency between plt.show() and fig.savefig(...) [Bug]: Rendering inconsistency between matplotlib-inline plt.show() and fig.savefig(...) Oct 26, 2022
@QuLogic
Copy link
Member
QuLogic commented Oct 26, 2022

ipywidgets uses the inline backend to display plots. That defaults to a tight bbox.

It can be disabled by overriding their save options.

Going to close as not our bug.

@QuLogic QuLogic closed this as not planned Won't fix, can't repro, duplicate, stale Oct 26, 2022
@posita
Copy link
Author
posita commented Oct 26, 2022

Thanks for the insight! That actually led me to finding jupyter/notebook#2640, including jupyter/notebook#2640 (comment) , which offers a way to bring parity.

UPDATE: Hah! Beat me to it, @QuLogic!

@posita posita closed this as completed Oct 26, 2022
@tacaswell
Copy link
Member

To be very clear here, there are a bunch of moving parts:

  • matplotlib
  • matplotlib.pyplot (the stateful MATLAB-like plotting application we provide)
  • malptolib-inline (the backend you are using that is externally maintained)
  • ipywidgets (which is involved in the example but not responsible for any of the problems)

that have become a bit tangled. I just want to make sure we are not talking past each other.

The inline backend (which used to be part of IPython and is now its own package) partially replicates the desktop interactive backends by dropping a static png into the output cells of the notebook when it would have normally poped up a GUI window. The png is embeded in the notebook as a data URL with the base64 encoded binary and the png data is rendered using (effectively) fig.savefig(buf, bbox_inches='tight'). The use of bbox_inches='tight' is the key detail as it clips the figure size to exactly fit the artists in the Figure. The somewhat surprising behavior of this clipping is that in addition to making the Figure smaller (trimming whitespace), it also can make the figure effectively bigger.

A minimal example to reproduce this behavior:

import matplotlib.pyplot
from fractions import Fraction


import matplotlib.pyplot as plt

data = {
    3: Fraction(1, 216),
    4: Fraction(1, 72),
    5: Fraction(1, 36),
    6: Fraction(5, 108),
    7: Fraction(5, 72),
    8: Fraction(7, 72),
    9: Fraction(25, 216),
    10: Fraction(1, 8),
    11: Fraction(1, 8),
    12: Fraction(25, 216),
    13: Fraction(7, 72),
    14: Fraction(5, 72),
    15: Fraction(5, 108),
    16: Fraction(1, 36),
    17: Fraction(1, 72),
    18: Fraction(1, 216),
}

fig, ax = matplotlib.pyplot.subplots(figsize=(4, 4))
unique_outcomes = sorted(data.keys())
ax.set_title(
    "Super duper duper long title that will probably exceed the width of the pie chart",
    fontdict={
        "fontweight": "bold",
    },
)
outcomes, values = zip(*((outcome, float(value)) for outcome, value in data.items()))
ax.pie(outcomes, values)
# this will clip the title, but will be the size you asked for
fig.savefig('test1.png')                       
# this will include the full title, but it is bigger than you asked for and not square
fig.savefig('test2.png', bbox_inches='tight')

There is no chance that we will change the default behavior of savefig to do this resizing (targeting fixed-sized figures is a major part of what we do) and I think it is also far too late for matplotlib-inline to change their default (the wave of bug reports of people who implicitly depended on this would be huge!).

I think you option are either to pass bbox_inches='tight' when you save or configure the inline backend to not pass it (I think

%config InlineBackend.print_figure_kwargs = {'bbox_inches':None}

will work interactively and there is a way to put this in your profile configuration files).

https://stackoverflow.com/questions/37864735/matplotlib-and-ipython-notebook-displaying-exactly-the-figure-that-will-be-save


I took the liberty of updating the title to include matplotlib-inline.

@posita
Copy link
Author
posita commented Oct 29, 2022

Thanks @tacaswell! That is a great breakdown and very helpful not only for me in understanding the layers, but hopefully for folks developing JupyterLite or anyone else who stumbles across this issue. I really appreciate you taking the time to lay it all out in such a way that even a stranger to these technologies (like me) can easily understand. It am especially grateful for the layout of possible options and explanations of why some were off the table. I feel much more aware of things now.

Many, many thanks again! 🙇

@tacaswell
Copy link
Member

@posita Your welcome!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
status: needs clarification Issues that need more information to resolve.
Projects
None yet
Development

No branches or pull requests

4 participants
0