Get text bounding box, independent of backend - python

I would like to get the bounding box (dimensions) around some text in a matplotlib figure. The post here, helped me realize that I can use the method text.get_window_extent(renderer) to get the bounding box, but I have to supply the correct renderer. Some backends do not have the method figure.canvas.get_renderer(), so I tried matplotlib.backend_bases.RendererBase() to get the renderer and it did not produce satisfactory results. Here is a simple example
import matplotlib as mpl
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
fig = plt.figure()
ax = plt.subplot()
txt = fig.text(0.15,0.5,'afdjsklhvvhwd', fontsize = 36)
renderer1 = fig.canvas.get_renderer()
renderer2 = mpl.backend_bases.RendererBase()
bbox1 = txt.get_window_extent(renderer1)
bbox2 = txt.get_window_extent(renderer2)
rect1 = Rectangle([bbox1.x0, bbox1.y0], bbox1.width, bbox1.height, \
color = [0,0,0], fill = False)
rect2 = Rectangle([bbox2.x0, bbox2.y0], bbox2.width, bbox2.height, \
color = [1,0,0], fill = False)
fig.patches.append(rect1)
fig.patches.append(rect2)
plt.draw()
This produces the following plot:
Clearly the red box is too small. I think a Paul's answer here found the same issue. The black box looks great, but I cannot use the MacOSX backend, or any others that do not have the method figure.canvas.get_renderer().
In case it matters, I am on Mac OS X 10.8.5, Matplotlib 1.3.0, and Python 2.7.5

Here is my solution/hack. #tcaswell suggested I look at how matplotlib handles saving figures with tight bounding boxes. I found the code for backend_bases.py on Github, where it saves the figure to a temporary file object simply in order to get the renderer from the cache. I turned this trick into a little function that uses the built-in method get_renderer() if it exists in the backend, but uses the save method otherwise.
def find_renderer(fig):
if hasattr(fig.canvas, "get_renderer"):
#Some backends, such as TkAgg, have the get_renderer method, which
#makes this easy.
renderer = fig.canvas.get_renderer()
else:
#Other backends do not have the get_renderer method, so we have a work
#around to find the renderer. Print the figure to a temporary file
#object, and then grab the renderer that was used.
#(I stole this trick from the matplotlib backend_bases.py
#print_figure() method.)
import io
fig.canvas.print_pdf(io.BytesIO())
renderer = fig._cachedRenderer
return(renderer)
Here are the results using find_renderer() with a slightly modified version of the code in my original example. With the TkAgg backend, which has the get_renderer() method, I get:
With the MacOSX backend, which does not have the get_renderer() method, I get:
Obviously, the bounding box using MacOSX backend is not perfect, but it is much better than the red box in my original question.

If you would like to get the tight bounding box of a rotated text region, here is a possible solution.
# generate text layer
def text_on_canvas(text, myf, ro, margin = 1):
axis_lim = 1
fig = plt.figure(figsize = (5,5), dpi=100)
plt.axis([0, axis_lim, 0, axis_lim])
# place the left bottom corner at (axis_lim/20,axis_lim/20) to avoid clip during rotation
aa = plt.text(axis_lim/20.,axis_lim/20., text, ha='left', va = 'top', fontproperties = myf, rotation = ro, wrap=True)
plt.axis('off')
text_layer = fig2img(fig) # convert to image
plt.close()
we = aa.get_window_extent()
min_x, min_y, max_x, max_y = we.xmin, 500 - we.ymax, we.xmax, 500 - we.ymin
box = (min_x-margin, min_y-margin, max_x+margin, max_y+margin)
# return coordinates to further calculate the bbox of rotated text
return text_layer, min_x, min_y, max_x, max_y
def geneText(text, font_family, font_size, style):
myf = font_manager.FontProperties(fname=font_family, size=font_size)
ro = 0
if style < 8: # rotated text
# no rotation, just to get the minimum bbox
htext_layer, min_x, min_y, max_x, max_y = text_on_canvas(text, myf, 0)
# actual rotated text
ro = random.randint(0, 90)
M = cv2.getRotationMatrix2D((min_x,min_y),ro,1)
# pts is 4x3 matrix
pts = np.array([[min_x, min_y, 1],[max_x, min_y, 1],[max_x, max_y, 1],[min_x, max_y,1]]) # clockwise
affine_pts = np.dot(M, pts.T).T
#print affine_pts
text_layer, _, _, _, _ = text_on_canvas(text, myf, ro)
visualize_points(htext_layer, pts)
visualize_points(text_layer, affine_pts)
return text_layer
else:
raise NotImplementedError
fonts = glob.glob(fonts_path + '/*.ttf')
ret = geneText('aaaaaa', fonts[0], 80, 1)
The result looks like this: The first one is un-rotated, and the second one is rotated text region. The full code snippet is here.

Related

How to measure a text element in matplotlib

I need to lay out a table full of text boxes using matplotlib. It should be obvious how to do this: create a gridspec for the table members, fill in each element of the grid, take the maximum heights and widths of the elements in the grid, change the appropriate height and widths of the grid columns and rows. Easy peasy, right?
Wrong.
Everything works except the measurements of the items themselves. Matplotlib consistently returns the wrong size for each item. I believe that I have been able to track this down to not even being able to measure the size of a text path correctly:
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatch
import matplotlib.text as mtext
import matplotlib.path as mpath
import matplotlib.patches as mpatches
fig, ax = plt.subplots(1, 1)
ax.set_axis_off()
text = '!?' * 16
size=36
## Buildand measure hidden text path
text_path=mtext.TextPath(
(0.0, 0.0),
text,
prop={'size' : size}
)
vertices = text_path.vertices
code = text_path.codes
min_x, min_y = np.min(
text_path.vertices[text_path.codes != mpath.Path.CLOSEPOLY], axis=0)
max_x, max_y = np.max(
text_path.vertices[text_path.codes != mpath.Path.CLOSEPOLY], axis=0)
## Transform measurement to graph units
transData = ax.transData.inverted()
((local_min_x, local_min_y),
(local_max_x, local_max_y)) = transData.transform(
((min_x, min_y), (max_x, max_y)))
## Draw a box which should enclose the path
x_offset = (local_max_x - local_max_y) / 2
y_offset = (local_max_y - local_min_y) / 2
local_min_x = 0.5 - x_offset
local_min_y = 0.5 - y_offset
local_max_x = 0.5 + x_offset
local_max_y = 0.5 + y_offset
path_data = [
(mpath.Path.MOVETO, (local_min_x, local_min_y)),
(mpath.Path.LINETO, (local_max_x, local_min_y)),
(mpath.Path.LINETO, (local_max_x, local_max_y)),
(mpath.Path.LINETO, (local_min_x, local_max_y)),
(mpath.Path.LINETO, (local_min_x, local_min_y)),
(mpath.Path.CLOSEPOLY, (local_min_x, local_min_y)),
]
codes, verts = zip(*path_data)
path = mpath.Path(verts, codes)
patch = mpatches.PathPatch(
path,
facecolor='white',
edgecolor='red',
linewidth=3)
ax.add_patch(patch)
## Draw the text itself
item_textbox = ax.text(
0.5, 0.5,
text,
bbox=dict(boxstyle='square',
fc='white',
ec='white',
alpha=0.0),
transform=ax.transAxes,
size=size,
horizontalalignment="center",
verticalalignment="center",
alpha=1.0)
plt.show()
Run this under Python 3.8
Expect: the red box to be the exact height and width of the text
Observe: the red box is the right height, but is most definitely not the right width.
There doesn't seem to be any way to do this directly, but there's a way to do it indirectly: instead of using a text box, use TextPath, transform it to Axis coordinates, and then use the differences between min and max on each coordinate. (See https://matplotlib.org/stable/gallery/text_labels_and_annotations/demo_text_path.html#sphx-glr-gallery-text-labels-and-annotations-demo-text-path-py for a sample implementation. This implementation has a significant bug -- it uses vertices and codes directly, which break in the case of a clipped text path.)

Keep original image data when saving to pdf

I have plots that I annotate using images:
def add_image(axe, filename, position, zoom):
img = plt.imread(filename)
off_img = matplotlib.offsetbox.OffsetImage(img, zoom = zoom, resample = False)
art = matplotlib.offsetbox.AnnotationBbox(off_img, position, xybox = (0, 0),
xycoords = axe.transAxes, boxcoords = "offset points", frameon = False)
axe.add_artist(art)
Then I save the figure to some pdf file, say fig.pdf. I expect the exact original image to be embedded in the resulting pdf, without resampling. However, the image is resampled according to the dpi parameter of savefig().
How can I force matplotlib to NOT resample the image (there is no point in doing that for a vector output anyway) ?
For more details, here is a simple example, using this image as image.png:
import numpy as np
import matplotlib
matplotlib.use("agg")
import matplotlib.pyplot as plt
def add_image(axe, filename, position, zoom):
img = plt.imread(filename)
off_img = matplotlib.offsetbox.OffsetImage(img, zoom = zoom, resample = False)
art = matplotlib.offsetbox.AnnotationBbox(off_img, position, xybox = (0, 0),
xycoords = axe.transAxes, boxcoords = "offset points", frameon = False)
axe.add_artist(art)
# ==========
fig = plt.figure()
axe = plt.axes()
fig.set_size_inches(3, 1.5)
axe.plot(np.arange(10), np.arange(10))
add_image(axe, "image.png", position = (0.2, 0.7), zoom = 0.07)
fig.savefig("temp.pdf", bbox_inches = "tight", pad_inches = 0)
Expected result:
Actual result:
EDIT: There is a bug/feature issue for this question
Just a quick summary of the discussion in https://github.com/matplotlib/matplotlib/issues/16268:
Passing the image through without resampling is indeed a desireable feature, mostly because for vector output, it should really be up to the renderer (e.g. pdf viewer, printer etc.) to determine the resolution.
The fact that matplotlib currently does not allow for this is mostly an oversight.
A workaround solution (a quick hack) is to add the following code before producing the figure:
from matplotlib.backends.backend_mixed import MixedModeRenderer
def _check_unsampled_image(self, renderer):
if isinstance(renderer, MixedModeRenderer):
return True
else:
return False
matplotlib.image.BboxImage._check_unsampled_image = _check_unsampled_image
This is not meant to be used in production code though, and a more robust solution needs to be implemented in a future matplotlib version. Contributions are welcome.

Matplotlib: A bug in interactive zooming tool after updating figure

I am stuck again with interactive plotting with matplotlib.
Everything else works like a charm (hovering and clicking of objects in a figure) but if I zoom the shown figure and it will be updated, zooming rectangle will remain in the new figure. Probably I have to reset zooming settings somehow but I couldn't find out the correct method to do it from other StackOverflow questions (clearing the figure is not obviously enough).
I built a toy example to illustrate the problem. Four points are attached to four images and they are plotted to the figure. With interactive-mode by inserting cursor on top of chosen point, it shows related image in a imagebox. After one point is clicked, program waits 2 seconds and updates the view by rotating all the samples 15 degrees.
The problem occurs when current view is zoomed and then its updated. Zoom-to-rectangle will start automatically and after clicking once anywhere in the figure, the rectangle is gone without doing anything. This is shown in below image. I just want to have normal cursor after figure is updated.
Here is the code for the toy example:
import matplotlib.pyplot as plt
import matplotlib as mpl
from matplotlib.offsetbox import OffsetImage, AnnotationBbox
import numpy as np
import copy
def initialize_figure(fignum):
plt.figure(fignum)
plt.clf()
def draw_interactive_figures(new_samples, images):
global new_samples_tmp, images_tmp, offset_image_tmp, image_box_tmp, fig_tmp, x_tmp, y_tmp
initialize_figure(1)
plt.ion()
fig_tmp = plt.gcf()
images_tmp = copy.deepcopy(images)
offset_image_tmp = OffsetImage(images_tmp[0,:,:,:])
image_box_tmp = (40., 40.)
x_tmp = new_samples[:,0]
y_tmp = new_samples[:,1]
new_samples_tmp = copy.deepcopy(new_samples)
update_plot()
fig_tmp.canvas.mpl_connect('motion_notify_event', hover)
fig_tmp.canvas.mpl_connect('button_press_event', click)
plt.show()
fig_tmp.canvas.start_event_loop()
plt.ioff()
def update_plot():
global points_tmp, annotationbox_tmp
ax = plt.gca()
points_tmp = plt.scatter(*new_samples_tmp.T, s=14, c='b', edgecolor='k')
annotationbox_tmp = AnnotationBbox(offset_image_tmp, (0,0), xybox=image_box_tmp, xycoords='data', boxcoords='offset points', pad=0.3, arrowprops=dict(arrowstyle='->'))
ax.add_artist(annotationbox_tmp)
annotationbox_tmp.set_visible(False)
def hover(event):
if points_tmp.contains(event)[0]:
inds = points_tmp.contains(event)[1]['ind']
ind = inds[0]
w,h = fig_tmp.get_size_inches()*fig_tmp.dpi
ws = (event.x > w/2.)*-1 + (event.x <= w/2.)
hs = (event.y > h/2.)*-1 + (event.y <= h/2.)
annotationbox_tmp.xybox = (image_box_tmp[0]*ws, image_box_tmp[1]*hs)
annotationbox_tmp.set_visible(True)
annotationbox_tmp.xy =(x_tmp[ind], y_tmp[ind])
offset_image_tmp.set_data(images_tmp[ind,:,:])
else:
annotationbox_tmp.set_visible(False)
fig_tmp.canvas.draw_idle()
def click(event):
if points_tmp.contains(event)[0]:
inds = points_tmp.contains(event)[1]['ind']
ind = inds[0]
initialize_figure(1)
update_plot()
plt.scatter(x_tmp[ind], y_tmp[ind], s=20, marker='*', c='y')
plt.pause(2)
fig_tmp.canvas.stop_event_loop()
fig_tmp.canvas.draw_idle()
def main():
fig, ax = plt.subplots(1, figsize=(7, 7))
points = np.array([[1,1],[1,-1],[-1,1],[-1,-1]])
zero_layer = np.zeros([28,28])
one_layer = np.ones([28,28])*255
images = np.array([np.array([zero_layer, zero_layer, one_layer]).astype(np.uint8),np.array([zero_layer, one_layer, zero_layer]).astype(np.uint8),np.array([one_layer, zero_layer, zero_layer]).astype(np.uint8),np.array([one_layer, zero_layer, one_layer]).astype(np.uint8)])
images = np.transpose(images, (0,3,2,1))
theta = 0
delta = 15 * (np.pi/180)
rotation_matrix = np.array([[np.cos(theta),-np.sin(theta)],[np.sin(theta),np.cos(theta)]])
while True:
rotated_points = np.matmul(points, rotation_matrix)
draw_interactive_figures(rotated_points, images)
theta += delta
rotation_matrix = np.array([[np.cos(theta),-np.sin(theta)],[np.sin(theta),np.cos(theta)]])
if __name__== "__main__":
main()
Thanks in advance!
I'm providing you with a starting point here. The following is a script that creates a plot and allows you to add new points by clicking on the axes. For each point one may mouse hover and show a respective image.
import matplotlib.pyplot as plt
from matplotlib.offsetbox import OffsetImage, AnnotationBbox
import numpy as np
class MyInteractivePlotter():
def __init__(self):
self.fig, self.ax = plt.subplots()
self.ax.set(xlim=(0,1), ylim=(0,1))
self.points = np.array([[0.5,0.5]]) # will become N x 2 array
self.images = [np.random.rand(10,10)]
self.scatter = self.ax.scatter(*self.points.T)
self.im = OffsetImage(self.images[0], zoom=5)
self.ab = AnnotationBbox(self.im, (0,0), xybox=(50., 50.), xycoords='data',
boxcoords="offset points", pad=0.3,
arrowprops=dict(arrowstyle="->"))
# add it to the axes and make it invisible
self.ax.add_artist(self.ab)
self.ab.set_visible(False)
self.cid = self.fig.canvas.mpl_connect("button_press_event", self.onclick)
self.hid = self.fig.canvas.mpl_connect("motion_notify_event", self.onhover)
def add_point(self):
# Update points (here, we just add a new random point)
self.points = np.concatenate((self.points, np.random.rand(1,2)), axis=0)
# For each points there is an image. (Here, we just add a random one)
self.images.append(np.random.rand(10,10))
# Update the scatter plot to show the new point
self.scatter.set_offsets(self.points)
def onclick(self, event):
self.add_point()
self.fig.canvas.draw_idle()
def onhover(self, event):
# if the mouse is over the scatter points
if self.scatter.contains(event)[0]:
# find out the index within the array from the event
ind, = self.scatter.contains(event)[1]["ind"]
# make annotation box visible
self.ab.set_visible(True)
# place it at the position of the hovered scatter point
self.ab.xy = self.points[ind,:]
# set the image corresponding to that point
self.im.set_data(self.images[ind])
else:
#if the mouse is not over a scatter point
self.ab.set_visible(False)
self.fig.canvas.draw_idle()
m = MyInteractivePlotter()
plt.show()
I would suggest you take this and add your functionality into it. Once you stumble upon a problem you can use it to ask for clarifications.

Why can't I add an image on top of my Matplotlib/Cartopy map?

I am working on a project that involves plotting data on a map with Cartopy.
Everything has been working so far, but I have been refactoring the code to make different functions callable by other parts of the program. So to that end, I have one function which adds my background to the map, and another which adds a placemarker at a specified lat/lon. Obviously, I want the placemarker above the background, but I can't seem to make it work.
For the background, I want to be able to use Cartopy stock images or web map tiles. The problem is the same either way, so I am using the Cartopy background for current testing purpose. Here is that function:
def custom_background(self, source_point):
cartmap = self.plot
source_point = source_point.split(" ")
source_point = (float(source_point[0]), float(source_point[1]))
dx = 2.5
dy = 5
pad = 0.5
lon_min, lon_max = source_point[0]-dx, source_point[0]+dx
lat_min, lat_max = source_point[1]-dy, source_point[1]+dy
area = 4*dx*dy
zoom = self.get_zoom(area) ##only relevant when using a map tile
cartmap.set_extent([lat_min-pad, lat_max+pad, lon_min-pad, lon_max+pad])
#~ cartmap.add_image(self.tile, zoom)
cartmap.add_feature(cartopy.feature.LAND, zorder=1)
return cartmap
Here is the placemark function:
def add_point_icon(self, x, y, cartmap):
src_point = np.array(Image.open('icons/icon63.png'))
im = OffsetImage(src_point, zoom=1, alpha=1.0, zorder=3)
ab = AnnotationBbox(im, (x,y), xycoords='data', frameon=False)
cartmap.add_artist(ab)
Both of these are called one after the other like so:
cartmap = self.custom_background(mysrc)
#~ cartmap=self.plot
self.add_point_icon(x1, y1, cartmap)
Results:
If I run the code as it is, this is how the map looks:
If I change it to (i.e. bypassing the function which draws the background):
#~ cartmap = self.custom_background(mysrc)
cartmap=self.plot
self.add_point_icon(x1, y1, cartmap)
Then I get:
Why can't I get the red "plus" sign to show up on top of the map? I've tried setting the "zorder" parameter of the different objects and it doesn't seem to do anything. I'm at a complete loss right now. Any help would be hugely appreciated, thanks.
Edit: perhaps I should also include the lines which create the subplot:
def __init__(self, mylevs):
self.fig, self.header, self.footer, self.plot, self.legend =
self.create_spec()
def create_spec(self):
"""Define layout of figure"""
#left column: header, footer, plt
fig = plt.figure(figsize=(12,10))
layout = 1
if layout == 1: #Default
widths = [8,1]
heights = [2, 10, 3]
column_border = 0.75
pad = 0.1
colorbar_width = 0.05
spec = gridspec.GridSpec(ncols=1, nrows=3, width_ratios = [1], height_ratios=heights, left=0.1, right = column_border)
#right column: colorbar
spec2 = gridspec.GridSpec(ncols=1, nrows=1, width_ratios = [1], height_ratios=[1], left=column_border+pad, right=column_border+pad+colorbar_width)
header = plt.subplot(spec[0,0])
footer = plt.subplot(spec[2,0])
plot = plt.subplot(spec[1,0], projection=cimgt.OSM().crs)
legend = plt.subplot(spec2[0,0])
return fig, header, footer, plot, legend
The problem was that in switching back to the Cartopy stock background from the map tiles, I forgot to switch the projection from ccrs.OSM() back to ccrs.PlateCarree(). In the map tile projection, the placemark was being plotted outside the viewing window.

Add a legend for an animation (of Artists) in matplotlib

I have made an animation from a set of images like this (10 snapshots):
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Circle
import time
infile = open ('out.txt')
frame_counter = 0
N_p = 100
N_step = 10
N_line = N_p*N_step
for s in xrange(N_step):
x, y = [], []
for i in xrange(N_p):
data = infile.readline()
raw = data.split()
x.append(float(raw[0]))
y.append(float(raw[1]))
xnp = np.array(x)
ynp = np.array(y)
fig = plt.figure(0)
ax = fig.add_subplot(111, aspect='equal')
for x, y in zip(xnp, ynp):
cir = Circle(xy = (x, y), radius = 1)
cir.set_facecolor('red')
ax.add_artist(cir)
cir.set_clip_box(ax.bbox)
ax.set_xlim(-10, 150)
ax.set_ylim(-10, 150)
fig.savefig("step.%04d.png" % frame_counter)
ax.remove()
frame_counter +=1
Now I want to add a legend to each image showing the time step.
For doing this I must set legends to each of these 10 images. The problem is that I have tested different things like ax.set_label , cir.set_label, ...
and I get errors like this:
UserWarning: No labelled objects found. Use label='...' kwarg on individual plots
According to this error I must add label to my individual plots, but since this is a plot of Artists, I don't know how I can do this.
If for whatever reason you need a legend, you can show your Circle as the handle and use some text as the label.
ax.legend(handles=[cir], labels=["{}".format(frame_counter)])
If you don't really need a legend, you can just use some text to place inside the axes.
ax.text(.8,.8, "{}".format(frame_counter), transform=ax.transAxes)

Categories