Jörn Hees
Jörn Hees

Reputation: 3428

specifying matplotlib axis content size

Let's say I'm trying to plot a picture of a black hole with x*y pixels (or inches). Unlike many others, i don't want to specify the total figure size, but the size of the plot's content (so everything in the frame, excluding the frame itself, ticks, labels, colorbars, margins, ...).

What's the preferred way to do that?


Own attempts

I've had this problem a couple of times already, but i'd now prefer some other method than my previous trial-and-error guess iteration of sizes... here's my "playground code":

import numpy as np

%matplotlib inline
import matplotlib
from matplotlib import pyplot as plt
import notebook

print(matplotlib.__version__, notebook.__version__)
# 3.0.0, 5.7.0 in my case

# some data
x, y = 456, 123
a = np.random.randn(y, x)

# the following at least gets the actual figure size in browser right,
# but if possible i'd like to avoid large white space margins as well...
# %config InlineBackend.print_figure_kwargs = {'bbox_inches': None}

dpi = plt.rcParams['figure.dpi']
fig, ax = plt.subplots(figsize=(x/dpi, y/dpi), dpi=dpi)
im = ax.imshow(a, interpolation='none')
cb = fig.colorbar(im)

# print sizes
bbox = ax.get_window_extent().transformed(fig.dpi_scale_trans.inverted())
print(f"content size: ({bbox.width}, {bbox.height}) inch, ({bbox.width*fig.dpi}, {bbox.height*fig.dpi}) px")
print(f"fig size:     {fig.get_size_inches()} in, {fig.dpi*fig.get_size_inches()} px")
print(f"dpi: {fig.dpi}")

output:

content size: (4.023888888888889, 1.3870138888888888) inch, (289.72, 99.865) px
fig size:     [6.33333333 1.70833333] in, [456. 123.] px
dpi: 72.0

sample output

As you can see the printed figure size is 456x123 px, but if you actually inspect the uploaded picture (copy pasted from the browser), you'll see that it's only 376x119 px. While this can be fixed (as commented in the code), the actual "content" size independent of that stays 282x75 px :-/.

Any ideas?

Upvotes: 0

Views: 1244

Answers (1)

ImportanceOfBeingErnest
ImportanceOfBeingErnest

Reputation: 339120

There is unfortunately no preferred way for this. Some more or less complicated workarounds come to mind.

A. Making the figure as large as the image, expand it while saving

If the aim is mainly to produce an image file of the figure, the easiest might be to make the axes for the image plot exactly as large as the figure, then let the final image file be expanded via the bbox_inches="tight" option.

It will require to manually place a colorbar outside the figure though.

import numpy as np
import matplotlib.pyplot as plt

#create some image, with lines every second pixel
rows = 123
cols = 456
image = np.zeros((rows,cols))
image[:, np.arange(0,image.shape[1], 2)] = 1
image[np.arange(0,image.shape[0], 2), :] = 0.5

dpi = 100
fig, ax = plt.subplots(figsize=(image.shape[1]/dpi, image.shape[0]/dpi), dpi=dpi)
fig.subplots_adjust(0,0,1,1)

im = ax.imshow(image)

cax = fig.add_axes([1.05, 0, 0.03, 1])
fig.colorbar(im, cax=cax)

fig.savefig("test.png", bbox_inches="tight")

enter image description here

The main drawback of this is that it might result in an image which is one pixel wrong. This is due to the positions always being in figure coordinates, resulting in rounding errors when stamping the axes size to pixels.

E.g. if in the above one chooses a dpi=69, the result would be

enter image description here

The interleaved lines make it easy to spot that the image is one pixels too small in height.

B. Make the figure larger than the image, adjust margins

One drawback of the above is that the axes decorations and the colorbar are outside the figure. To have them inside, one can define all margins and calculate how large the final figure will need to be. This is a but cumbersome.

import numpy as np
import matplotlib.pyplot as plt

#create some image
rows = 123
cols = 456
image = np.zeros((rows,cols))
image[:, np.arange(0,image.shape[1], 2)] = 1
image[np.arange(0,image.shape[0], 2), :] = 0.5

dpi = 100

left = right = 60
top = bottom = 40
cbarwidth = 24
wspace = 10

width = left + cols + wspace + cbarwidth + right
height = top + rows + bottom

w = width / dpi
h = height / dpi

fig, (ax, cax) = plt.subplots(ncols = 2, figsize=(w,h), dpi=dpi, 
                              gridspec_kw=dict(width_ratios=[cols, cbarwidth]))
fig.subplots_adjust(left = left/width, right = 1-right/width, 
                    bottom = bottom/height, top = 1-top/height, 
                    wspace = wspace / (cols + cbarwidth))

im = ax.imshow(image)
fig.colorbar(im, cax=cax)

fig.savefig("test2.png")

enter image description here

It will also suffer from the same flaw as A., e.g. if using some odd numbers like

dpi = 72

left = right = 59
top = bottom = 37
cbarwidth = 19
wspace = 12

enter image description here

C. Use a figimage and lay axes on top.

The only way to assure there is no aliasing effects is to use a figimage. This places the image in pixel coordinates into the figure. However, one will then not have any axes by default. A solution has been proposed recently by @anntzer, which is to just place an axes at the position in the figure, where the figimage is.

import numpy as np
import matplotlib.pyplot as plt
from matplotlib.transforms import Bbox

#create some image, with lines every second pixel
rows = 123
cols = 456
image = np.zeros((rows,cols))
image[:, np.arange(0,image.shape[1], 2)] = 1
image[np.arange(0,image.shape[0], 2), :] = 0.5

dpi = 100

left = right = 60
top = 40
bottom = 65
cbarwidth = 24
wspace = 10

width = left + cols + wspace + cbarwidth + right
height = top + rows + bottom

w = width / dpi
h = height / dpi


fig = plt.figure(figsize=(w,h), dpi=dpi)

im = fig.figimage(image, xo=left, yo=bottom); 

# create axes on top
# bbox in pixels
bbox = Bbox([[left, bottom], [left + cols, bottom + rows]])
ax = fig.add_axes(fig.transFigure.inverted().transform_bbox(bbox))
ax.set_facecolor("None")
# recreate axis limits
ax.set(xlim=(-0.5, cols-0.5), ylim=(rows-0.5, -0.5))

# add colorbar
cbbox =  Bbox([[left + cols + wspace, bottom], 
               [left + cols + wspace + cbarwidth, bottom + rows]])
cax = fig.add_axes(fig.transFigure.inverted().transform_bbox(cbbox))
fig.colorbar(im, cax=cax)

fig.savefig("test3.png")

enter image description here

With this one can be sure that the image itself is undistorted. But the axes ticks may be off by a pixels or so, because they go through the figure transform. Also, I haven't thought through completely if the bbox coordinates need to be shifted by half a unit or not. (Comments are welcome on that last point!)

Upvotes: 3

Related Questions