If at first you don't succeed...

About | Archive

Setting the actual size of figures in matplotlib.pyplot

Let’s say you want to set the size of a figure in matplotlib, say because you want the captions to match the font size on a poster (this came up for me recently). However, you might find yourself with kinda a weird problem.

Directly setting the size of a figure

Setting the actual size of figures in matplotlib.pyplot is difficult. Setting the size of a figure as so

plt.figure(figsize=(5, 2.5))
plt.plot([1, 2, 3], [4, 5, 6])
plt.xlabel("x label")
plt.ylabel("y label")
plt.savefig("direct.png")
print(imread("direct.png").shape) # outputs (250, 500, 4)

It’s the right size in pixels, except that this image is padded weirdly and the x label is cut off (border added).

Tight Layout

The fix for this is to use a tight layout in the output

plt.figure(figsize=(5, 2.5))
plt.plot([1, 2, 3], [4, 5, 6])
plt.xlabel("x label")
plt.ylabel("y label")
plt.savefig("tight-layout.png", bbox_inches='tight')
print(imread("tight-layout.png").shape) # outputs (259, 462, 4)

This fixes the elements being dropped issue, but the image size is now incorrect (at 2.59x4.62 instead of 2.5x5).

Fixing the problem

First, we write a general function to get the size of a figure. We then calculate \(x_{\text{to set}} = x_{\text{set previously}} \frac{x_{\text{target}}}{x_{\text{actual}}}\) for \(x\) being the width and height. The intuition behind this equation is that we figure out how off the actual image’s size is from our target, and use this to update what we tell matplotlib to do.

However, this is an approximation, and we repeat it to get a better fit. The full code is below:

from matplotlib.image import imread
from tempfile import NamedTemporaryFile

def get_size(fig, dpi=100):
    with NamedTemporaryFile(suffix='.png') as f:
        fig.savefig(f.name, bbox_inches='tight', dpi=dpi)
        height, width, _channels = imread(f.name).shape
        return width / dpi, height / dpi

def set_size(fig, size, dpi=100, eps=1e-2, give_up=2, min_size_px=10):
    target_width, target_height = size
    set_width, set_height = target_width, target_height # reasonable starting point
    deltas = [] # how far we have
    while True:
        fig.set_size_inches([set_width, set_height])
        actual_width, actual_height = get_size(fig, dpi=dpi)
        set_width *= target_width / actual_width
        set_height *= target_height / actual_height
        deltas.append(abs(actual_width - target_width) + abs(actual_height - target_height))
        if deltas[-1] < eps:
            return True
        if len(deltas) > give_up and sorted(deltas[-give_up:]) == deltas[-give_up:]:
            return False
        if set_width * dpi < min_size_px or set_height * dpi < min_size_px:
            return False

Using this method (note that we no longer need to set the figure size when creating the figure)

fig = plt.figure()
fig.gca().plot([1, 2, 3], [4, 5, 6])
plt.gca().set_xlabel("x label")
plt.gca().set_ylabel("y label")
set_size(fig, (5, 2.5))
plt.savefig("update-size.png", bbox_inches='tight')
print(imread("update-size.png").shape) # outputs (250, 500, 4)

we get the following result, which is exactly the correct size.

What’s with the return False?

The reason that we check to see if the error is increasing is that sometimes it is impossible to fit a plot within a given space. For example, take the following example

fig = plt.figure()
fig.gca().plot([1, 2, 3], [4, 5, 6])
plt.gca().set_xlabel("x label")
plt.gca().set_ylabel("y label")
set_size(fig, (5, 2.5))
plt.savefig("update-size.png", bbox_inches='tight')
print(imread("update-size.png").shape) # outputs (250, 500, 4)

This ends up trying to decrease the height of the figure to below 0, but before it can cause a crash in matplotlib, it causes the last condition in set_size to be activated, leading to a False return value. The resulting image is 91x109, nowhere close to the 100x50 that it should be, and looks like this

By returning False, this allows e.g., a notebook to finish executing in cases where it is unecessary to check, but a simple assert set_size(fig, (1, 0.5)) would lead to an error if that is desired.

Conclusion

The example above is full, working, code, and should honestly probably be part of matplotlib: generally speaking, when I set the size of a figure, I intend to set the actual size of the figure, not the size of the plot with some minimal padding around it. In any case, hopefully this code is helpful to you!

comments powered by Disqus