Scale matplotlib text artist to fill rectangle patch bounding box - python

Given a rectangle patch and text artist in matplotlib, is it possible to scale the text such that it fills the rectangle as best as possible without overfilling on either dimension?
e.g.
import matplotlib as mpl
import matplotlib.pyplot as plt
f, ax = plt.subplots()
ax.set(xlim=(0, 6), ylim=(0, 3))
x0, y0 = 1, 1
x1, y1 = 5, 1.5
width = x1 - x0
height = y1 - y0
rect = mpl.patches.Rectangle((x0, y0), width, height, fc="none", ec="y")
ax.add_patch(rect)
text = ax.text(x0 + width / 2, y0 + height / 2, "yellow", ha="center", va="center")
Produces
I would like to programmatically rescale the text to fit within the rectangle (including descenders), akin to this result found using trial-and-error:
We can assume some simplifying constraints, namely that the figure size and axis limits are known and set ahead of time and don't need to change after this operation.

It is relatively simple to scale the text based on a comparison between its window extent and the window extent of the rectangle:
def rescale_text(rect, text):
r = rect.get_window_extent()
t = text.get_window_extent()
scale = min(r.height / t.height, r.width / t.width)
text.set_size(text.get_size() * scale)
However, it looks like the window extent height is computed based on the font, not the actual string. As a result, it properly handles descenders, but it continues to even when they're not present in the string, and it doesn't account for strings with all "short" characters, e.g.:
While this is probably passable for my application, if there were a way of getting the text extent tightened to what is actually being drawn, that would be ideal.

Related

How do I alter a subplot's aspect ratio, so it completely fills the space of its containing figure?

Here's a simplified version of what I'm trying that exhibits the same issue I'm experiencing.
import numpy as np
import matplotlib.pyplot as plt
plt.rcParams['toolbar'] = 'None'
class StackExample:
def __init__(self, coords):
self.coords = coords
# Create figure / axis, add points to axis, set their colors
# Width value chosen at random, height chosen to maintain the aspect ratio of the data
width = 4
height = 1.75 * width
# figsize = (width, height) does make the window generate at the size I want it to successfully.
self.fig = plt.figure(figsize=(width, height), dpi=100)
# Setting color to red for easier debugging
self.fig.set_facecolor('red')
self.ax = self.fig.add_subplot(111, projection='3d')
self.ax.set_position([0, 0, 1, 1.75])
self.ax.set_facecolor('lightgray')
# The problem is that the lightgray area always has an aspect ratio of 1:1.
# I cannot figure out how to change its shape to match that of its containing window.
# I would like it to have the same aspect ratio as self.fig, so no red is visible at all when the window generates.
self.scatter = self.ax.scatter(coords[:,0], coords[:,1], coords[:,2])
# Camera is parallel to the xy plane
self.ax.elev = 0
# Camera is pointing backward along the positive x-axis
self.ax.azim = 0
self.ax.dist = 6
# Adjust axis bounds
self.ax.set_xlim3d(-1, 1)
self.ax.set_ylim3d(-1, 1)
self.ax.set_zlim3d(0, 3.5)
# Set the aspect ratio.
self.ax.set_box_aspect((1, 1, 1.75))
# Adjust plot layout
self.fig.tight_layout()
self.fig.subplots_adjust(left=0, right=1, bottom=0, top=1)
# Show the plot - need `block=False` to not lock the thread
plt.show(block=False)
# 800 randomly generated points. x and y coordinates vary from -1 to 1, z coordinates vary from 0 to 3.5.
# Note that this means the data has a 1:1.75 width:height ratio when viewed from the positive x-axis
coords = np.random.rand(800, 3)
coords[:,0] = coords[:,0] * 2 - 1
coords[:,1] = coords[:,1] * 2 - 1
coords[:,2] = coords[:,2] * 3.5
tree = StackExample(coords)
When I run the code, it generates that output. I'm trying to force a 1:1.75 aspect ratio - and the red area, which is defined by self.fig, is correctly in that ratio. I would like to get self.ax, the lightgray subplot to self.fig, to expand to fill the window - so no red should be visible at all. The lightgray self.ax region is exactly a 1:1 aspect ratio, and I cannot figure out how to change that.
I've been messing with this for a while, and nothing I've tried has successfully made the lightgray region anything but a 1:1 square. It's difficult to go into specifics of what I tried, because I basically tried everything I could find when googling for what I want.
Note that I'm not asking how to change the aspect ratio of the actual axes that make up the graph, which is something the provided code already does - it's the actual part of the matplotlib window where points are displayed, shown in gray in the included image, that I want to modify.
I'm very much a matplotlib novice - am I missing something obvious here? This feels like it should be a very basic feature. I'm getting the impression that using a 3D projection makes all or almost all of the solutions people have posted online invalid.

x-axis labels crops figure size [duplicate]

I want to to create a figure using matplotlib where I can explicitly specify the size of the axes, i.e. I want to set the width and height of the axes bbox.
I have looked around all over and I cannot find a solution for this. What I typically find is how to adjust the size of the complete Figure (including ticks and labels), for example using fig, ax = plt.subplots(figsize=(w, h))
This is very important for me as I want to have a 1:1 scale of the axes, i.e. 1 unit in paper is equal to 1 unit in reality. For example, if xrange is 0 to 10 with major tick = 1 and x axis is 10cm, then 1 major tick = 1cm. I will save this figure as pdf to import it to a latex document.
This question brought up a similar topic but the answer does not solve my problem (using plt.gca().set_aspect('equal', adjustable='box') code)
From this other question I see that it is possible to get the axes size, but not how to modify them explicitly.
Any ideas how I can set the axes box size and not just the figure size. The figure size should adapt to the axes size.
Thanks!
For those familiar with pgfplots in latex, it will like to have something similar to the scale only axis option (see here for example).
The axes size is determined by the figure size and the figure spacings, which can be set using figure.subplots_adjust(). In reverse this means that you can set the axes size by setting the figure size taking into acount the figure spacings:
import matplotlib.pyplot as plt
def set_size(w,h, ax=None):
""" w, h: width, height in inches """
if not ax: ax=plt.gca()
l = ax.figure.subplotpars.left
r = ax.figure.subplotpars.right
t = ax.figure.subplotpars.top
b = ax.figure.subplotpars.bottom
figw = float(w)/(r-l)
figh = float(h)/(t-b)
ax.figure.set_size_inches(figw, figh)
fig, ax=plt.subplots()
ax.plot([1,3,2])
set_size(5,5)
plt.show()
It appears that Matplotlib has helper classes that allow you to define axes with a fixed size Demo fixed size axes
I have found that ImportanceofBeingErnests answer which modifies that figure size to adjust the axes size provides inconsistent results with the paticular matplotlib settings I use to produce publication ready plots. Slight errors were present in the final figure size, and I was unable to find a way to solve the issue with his approach. For most use cases I think this is not a problem, however the errors were noticeable when combining multiple pdf's for publication.
In lieu of developing a minimum working example to find the real issue I am having with the figure resizing approach I instead found a work around which uses the fixed axes size utilising the divider class.
from mpl_toolkits.axes_grid1 import Divider, Size
def fix_axes_size_incm(axew, axeh):
axew = axew/2.54
axeh = axeh/2.54
#lets use the tight layout function to get a good padding size for our axes labels.
fig = plt.gcf()
ax = plt.gca()
fig.tight_layout()
#obtain the current ratio values for padding and fix size
oldw, oldh = fig.get_size_inches()
l = ax.figure.subplotpars.left
r = ax.figure.subplotpars.right
t = ax.figure.subplotpars.top
b = ax.figure.subplotpars.bottom
#work out what the new ratio values for padding are, and the new fig size.
neww = axew+oldw*(1-r+l)
newh = axeh+oldh*(1-t+b)
newr = r*oldw/neww
newl = l*oldw/neww
newt = t*oldh/newh
newb = b*oldh/newh
#right(top) padding, fixed axes size, left(bottom) pading
hori = [Size.Scaled(newr), Size.Fixed(axew), Size.Scaled(newl)]
vert = [Size.Scaled(newt), Size.Fixed(axeh), Size.Scaled(newb)]
divider = Divider(fig, (0.0, 0.0, 1., 1.), hori, vert, aspect=False)
# the width and height of the rectangle is ignored.
ax.set_axes_locator(divider.new_locator(nx=1, ny=1))
#we need to resize the figure now, as we have may have made our axes bigger than in.
fig.set_size_inches(neww,newh)
Things worth noting:
Once you call set_axes_locator() on an axis instance you break the tight_layout() function.
The original figure size you choose will be irrelevent, and the final figure size is determined by the axes size you choose and the size of the labels/tick labels/outward ticks.
This approach doesn't work with colour scale bars.
This is my first ever stack overflow post.
another method using fig.add_axes was quite accurate. I have included 1 cm grid aswell
import matplotlib.pyplot as plt
import matplotlib as mpl
# This example fits a4 paper with 5mm margin printers
# figure settings
figure_width = 28.7 # cm
figure_height = 20 # cm
left_right_magrin = 1 # cm
top_bottom_margin = 1 # cm
# Don't change
left = left_right_magrin / figure_width # Percentage from height
bottom = top_bottom_margin / figure_height # Percentage from height
width = 1 - left*2
height = 1 - bottom*2
cm2inch = 1/2.54 # inch per cm
# specifying the width and the height of the box in inches
fig = plt.figure(figsize=(figure_width*cm2inch,figure_height*cm2inch))
ax = fig.add_axes((left, bottom, width, height))
# limits settings (important)
plt.xlim(0, figure_width * width)
plt.ylim(0, figure_height * height)
# Ticks settings
ax.xaxis.set_major_locator(mpl.ticker.MultipleLocator(5))
ax.xaxis.set_minor_locator(mpl.ticker.MultipleLocator(1))
ax.yaxis.set_major_locator(mpl.ticker.MultipleLocator(5))
ax.yaxis.set_minor_locator(mpl.ticker.MultipleLocator(1))
# Grid settings
ax.grid(color="gray", which="both", linestyle=':', linewidth=0.5)
# your Plot (consider above limits)
ax.plot([1,2,3,5,6,7,8,9,10,12,13,14,15,17])
# save figure ( printing png file had better resolution, pdf was lighter and better on screen)
plt.show()
fig.savefig('A4_grid_cm.png', dpi=1000)
fig.savefig('tA4_grid_cm.pdf')
result:

Matplotlib: Line2D height in pixels

I have for instance the following line drawn in matplotlib
import matplotlib.pyplot as plt
fig = plt.figure()
ax = fig.add_subplot(2,1,1) # two rows, one column, first plot
# This should be a straight line which spans the y axis
# from 0 to 50
line, = ax.plot([0]*50, range(50), color='blue', lw=2)
line2, = ax.plot([10]*100, range(100), color='blue', lw=2)
how can I get how many pixels that straight line is, in the y direction?
Note: I have several of these lines with gaps in between and I would like to put text next to them, however, if there are too many lines, I would need to know how much text I can add, that is the reason why I need the height of the line.
For instance in the attached photo, there is a blue line on the right hand side which is roughly 160 pixels in height. In a height of 160 pixels (with the font I am using) I can fit in roughly 8 lines of text as the height of the text is roughly 12 pixels in height.
How can I get the information on how tall the line is in pixels? Or is there a better way to lay the text out?
In order to obtain the height of a line in units of pixels you can use its bounding box. To make sure the bounding box is the one from the line as drawn on the canvas, you first need to draw the canvas. Then the bounding box is obtained via .line2.get_window_extent(). The difference between the upper end of the bounding box (y1) and the lower end (y0) is then the number of pixels you are looking for.
fig.canvas.draw()
bbox = line2.get_window_extent(fig.canvas.get_renderer())
# at this point you can get the line height:
print "Lineheight in pixels: ", bbox.y1 - bbox.y0
In order to draw text within the y-extent of the line, the following may be useful. Given a fontsize in points, e.g. fontsize = 12, you may calculate the size in pixels and then calculate the number of possible text lines to fit into the range of pixels determined above. Using a blended transform, where where x is in data units and y in pixels allows you to specify the x-coordinate in data units (here x=8) but the y coordinate in a coordinate in pixels calculated from the extent of the line.
import matplotlib.pyplot as plt
import matplotlib.transforms as transforms
fig = plt.figure()
ax = fig.add_subplot(2,1,1)
line, = ax.plot([0]*50, range(50), color='blue', lw=2)
line2, = ax.plot([10]*100, range(100), color='blue', lw=2)
fig.canvas.draw()
bbox = line2.get_window_extent(fig.canvas.get_renderer())
# at this point you can get the line height:
print "Lineheight in pixels: ", bbox.y1 - bbox.y0
#To draw text
fontsize=12 #pt
# fontsize in pixels:
fs_pixels = fontsize*fig.dpi/72.
#number of possible texts to draw:
n = (bbox.y1 - bbox.y0)/fs_pixels
# create transformation where x is in data units and y in pixels
trans = transforms.blended_transform_factory(ax.transData, transforms.IdentityTransform())
for i in range(int(n)):
ax.text(8.,bbox.y0+(i+1)*fs_pixels, "Text", va="top", transform=trans)
plt.show()

Matplotlib Rectangle With Color Gradient Fill

I want to draw a rectangle, with a gradient color fill from left to right, at an arbitrary position with arbitrary dimensions in my axes instance (ax1) coordinate system.
My first thought was to create a path patch and somehow set its fill as a color gradient. But according to THIS POST there isn't a way to do that.
Next I tried using a colorbar. I created a second axes instance ax2 using fig.add_axes([left, bottom, width, height]) and added a color bar to that.
ax2 = fig.add_axes([0, 0, width, height/8])
colors = [grad_start_color, grad_end_color]
index = [0.0, 1.0]
cm = LinearSegmentedColormap.from_list('my_colormap', zip(index, colors))
colorbar.ColorbarBase(ax2, cmap=cm, orientation='horizontal')
But the positional parameters passed to fig.add_axes() are in the coordinate system of fig, and don't match up with the coordinate system of ax1.
How can I do this?
I have been asking myself a similar question and spent some time looking for the answer to find in the end that this can quite easily be done by imshow:
from matplotlib import pyplot
pyplot.imshow([[0.,1.], [0.,1.]],
cmap = pyplot.cm.Greens,
interpolation = 'bicubic'
)
It is possible to specify a colormap, what interpolation to use and much more. One additional thing, I find very interesting, is the possibility to specify which part of the colormap to use. This is done by means of vmin and vmax:
pyplot.imshow([[64, 192], [64, 192]],
cmap = pyplot.cm.Greens,
interpolation = 'bicubic',
vmin = 0, vmax = 255
)
Inspired by this example
Additional Note:
I chose X = [[0.,1.], [0.,1.]] to make the gradient change from left to right. By setting the array to something like X = [[0.,0.], [1.,1.]], you get a gradient from top to bottom. In general, it is possible to specify the colour for each corner where in X = [[i00, i01],[i10, i11]], i00, i01, i10 and i11 specify colours for the upper-left, upper-right, lower-left and lower-right corners respectively. Increasing the size of X obviously allows to set colours for more specific points.
did you ever solve this? I wanted the same thing and found the answer using the coordinate mapping from here,
#Map axis to coordinate system
def maptodatacoords(ax, dat_coord):
tr1 = ax.transData.transform(dat_coord)
#create an inverse transversion from display to figure coordinates:
fig = ax.get_figure()
inv = fig.transFigure.inverted()
tr2 = inv.transform(tr1)
#left, bottom, width, height are obtained like this:
datco = [tr2[0,0], tr2[0,1], tr2[1,0]-tr2[0,0],tr2[1,1]-tr2[0,1]]
return datco
#Plot a new axis with a colorbar inside
def crect(ax,x,y,w,h,c,**kwargs):
xa, ya, wa, ha = maptodatacoords(ax, [(x,y),(x+w,y+h)])
fig = ax.get_figure()
axnew = fig.add_axes([xa, ya, wa, ha])
cp = mpl.colorbar.ColorbarBase(axnew, cmap=plt.get_cmap("Reds"),
orientation='vertical',
ticks=[],
**kwargs)
cp.outline.set_linewidth(0.)
plt.sca(ax)
Hopefully this helps anyone in the future who needs similar functionality. I ended up using a grid of patch objects instead.

Understanding matplotlib verts

I'm trying to create custom markers in matplotlib for a scatter plot, where the markers are rectangles with fix height and varying width. The width of each marker is a function of the y-value. I tried it like this using this code as a template and assuming that if verts is given a list of N 2-D tuples it plots rectangles with the width of the corresponing first value and the height of the second (maybe this is already wrong, but then how else do I accomplish that?).
I have a list of x and y values, each containing angles in degrees. Then, I compute the width and height of each marker by
field_size = 2.
symb_vec_x = [(field_size / np.cos(i * np.pi / 180.)) for i in y]
symb_vec_y = [field_size for i in range(len(y))]
and build the verts list and plot everything with
symb_vec = list(zip(symb_vec_x, symb_vec_y))
fig = plt.figure(1, figsize=(14.40, 9.00))
ax = fig.add_subplot(1,1,1)
sc = ax.scatter(ra_i, dec_i, marker='None', verts=symb_vec)
But the resulting plot is empty, no error message however. Can anyone tell me what I did wrong with defining the verts and how to do it right?
Thanks!
As mentioned 'marker='None' need to be removed then the appropriate way to specify a rectangle with verts is something like
verts = list(zip([-10.,10.,10.,-10],[-5.,-5.,5.,5]))
ax.scatter([0.5,1.0],[1.0,2.0], marker=(verts,0))
The vertices are defined as ([x1,x2,x3,x4],[y1,y2,y3,y4]) so attention must be paid to which get minus signs etc.
This (verts,0) is mentioned in the docs as
For backward compatibility, the form (verts, 0) is also accepted,
but it is equivalent to just verts for giving a raw set of vertices
that define the shape.
However I find using just verts does not give the correct shape.
To automate the process you need to do something like
v_val=1.0
h_val=2.0
verts = list(zip([-h_val,h_val,h_val,-h_val],[-v_val,-v_val,v_val,v_val]))
Basic example:
import pylab as py
ax = py.subplot(111)
v_val=1.0
h_val=2.0
verts = list(zip([-h_val,h_val,h_val,-h_val],[-v_val,-v_val,v_val,v_val]))
ax.scatter([0.5,1.0],[1.0,2.0], marker=(verts,0))
*
edit
Individual markers
So you need to manually create a vert for each case. This will obviously depend on how you want your rectangles to change point to point. Here is an example
import pylab as py
ax = py.subplot(111)
def verts_function(x,y,r):
# Define the vertex's multiplying the x value by a ratio
x = x*r
y = y
return [(-x,-y),(x,-y),(x,y),(-x,y)]
n=5
for i in range(1,4):
ax.scatter(i,i, marker=(verts_function(i,i,0.3),0))
py.show()
so in my simple case I plot the points i,i and draw rectangles around them. The way the vert markers are specified is non intuitive. In the documentation it's described as follows:
verts: A list of (x, y) pairs used for Path vertices. The center of
the marker is located at (0,0) and the size is normalized, such that
the created path is encapsulated inside the unit cell.
Hence, the following are equivalent:
vert = [(-300.0, -1000), (300.0, -1000), (300.0, 1000), (-300.0, 1000)]
vert = [(-0.3, -1), (0.3, -1), (0.3, 1), (-0.3, 1)]
e.g they will produce the same marker. As such I have used a ratio, this is where you need to do put in the work. The value of r (the ratio) will change which axis remains constant.
This is all getting very complicated, I'm sure there must be a better way to do this.
I got the solution from Ryan of the matplotlib users mailing list. It's quite elegant, so I will share his example here:
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
from matplotlib.collections import PatchCollection
n = 100
# Get your xy data points, which are the centers of the rectangles.
xy = np.random.rand(n,2)
# Set a fixed height
height = 0.02
# The variable widths of the rectangles
widths = np.random.rand(n)*0.1
# Get a color map and make some colors
cmap = plt.cm.hsv
colors = np.random.rand(n)*10.
# Make a normalized array of colors
colors_norm = colors/colors.max()
# Here's where you have to make a ScalarMappable with the colormap
mappable = plt.cm.ScalarMappable(cmap=cmap)
# Give it your non-normalized color data
mappable.set_array(colors)
rects = []
for p, w in zip(xy, widths):
xpos = p[0] - w/2 # The x position will be half the width from the center
ypos = p[1] - height/2 # same for the y position, but with height
rect = Rectangle( (xpos, ypos), w, height ) # Create a rectangle
rects.append(rect) # Add the rectangle patch to our list
# Create a collection from the rectangles
col = PatchCollection(rects)
# set the alpha for all rectangles
col.set_alpha(0.3)
# Set the colors using the colormap
col.set_facecolor( cmap(colors_norm) )
# No lines
col.set_linewidth( 0 )
#col.set_edgecolor( 'none' )
# Make a figure and add the collection to the axis.
fig = plt.figure()
ax = fig.add_subplot(111)
ax.add_collection(col)
# Add your ScalarMappable to a figure colorbar
fig.colorbar(mappable)
plt.show()
Thank you, Ryan, and everyone who contributed their ideas!

Categories