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.)
Related
So I have the following two arrays:
base = np.arange(2)
y_axis = [32.59, 28.096]
And the following code
base = np.arange(2)
fig,ax = plt.subplots()
fig.set_figheight(10)
fig.set_figwidth(15)
bars = ax.bar(base, y_axis, width = 0.3)
bars[0].set_color('g')
ax.bar_label(bars,[f'{i}%' for i in y_axis])
ax.set_xticks(base, labels = ['Simplificado','Não simplificados'])
ax.arrow(base[0],y5,dx = base[1], dy = x5-y5)
That results in the following image
What I want to do is a comparison, arrow something kinda like this. Any ideas on a way to build up such arrow?
Sorry for bad image.
You could use matplotlib.path.
That can be used to draw polygons or also just a polyline following a specific path as used for this case.
This plot isn't optimized to look pretty (see notes at the end for potential improvement), but to show the concept:
Code:
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.path as mpath
base = np.arange(2)
y_axis = [32.59, 28.096]
fig, ax = plt.subplots()
fig.set_figheight(10)
fig.set_figwidth(15)
path_y_gap = 5
delta_value = y_axis[1] - y_axis[0]
Path = mpath.Path
path_data = [
(Path.MOVETO, (base[0],y_axis[0])),
(Path.MOVETO, (base[0],y_axis[0]+path_y_gap)),
(Path.MOVETO, (base[1],y_axis[0]+path_y_gap)),
#(Path.MOVETO, (base[1],y_axis[1])), # alternative to the arrow
]
codes, verts = zip(*path_data)
path = mpath.Path(verts, codes)
x, y = zip(*path.vertices)
line, = ax.plot(x, y, 'k-')
ax.text( 0.5 , y_axis[0] + path_y_gap + 0.5, round(delta_value,2))
ax.arrow(base[1], y_axis[0]+path_y_gap, 0, -(-delta_value + path_y_gap),
head_width = 0.02 , head_length = 0.8, length_includes_head = True)
bars = ax.bar(base, y_axis, width = 0.3)
bars[0].set_color('g')
ax.bar_label(bars,[f'{i}%' for i in y_axis])
ax.set_xticks(base, labels = ['Simplificado','Não simplificados'])
Notes:
path doesn't offer arrow shaped ends, as a workaround the last section is done by a normal matplotlib arrow
Check the alternative in the path_data to the arrow for the last section
I haven't dealt with overlay of the bar % text and the path / arrow, but you could e.g. easily put a y-offset variable to start/end above that text
Check Bézier example in the matplotlib path tutorial if you prefer a 'rounded' line
You may for sure adapt the float digits another way than the used round()
The first MOVETO sets the starting point, an explicit endpoint isn't required.
The following sample code will generate the donut chart I'll use as my example:
import matplotlib.pyplot as plt
%matplotlib inline
# Following should supposedly set the font correctly:
plt.rcParams['font.family'] = 'sans-serif'
plt.rcParams['font.sans-serif'] = ['Muli'] + plt.rcParams['font.sans-serif']
plt.rcParams['font.weight'] = 'extra bold'
size_of_groups=[12,11,30,0.3]
colors = ['#a1daaa','#bbbbb4','#444511','#1afff2']
import matplotlib as mpl
mpl.rcParams['text.color'] = '#273859'
# Create a pieplot
my_pie,texts,_ = plt.pie(size_of_groups,radius = 1.2,colors=colors,autopct="%.1f%%",
textprops = {'color':'w',
'size':15 #, 'weight':"extra bold"
}, pctdistance=0.75, labeldistance=0.7) #pctdistance and labeldistance change label positions.
labels=['High','Low','Normal','NA']
plt.legend(my_pie,labels,loc='lower center',ncol=2,bbox_to_anchor=(0.5, -0.2))
plt.setp(my_pie, width=0.6, edgecolor='white')
fig1 = plt.gcf()
fig1.show()
The above outputs this:
Mostly, this is great. Finally I got a nice looking donut chart!
But there is just one last thing to finesse - when the portion of the donut chart is very small (like the 0.6%), I need the labels to be moved out of the chart, and possibly colored black instead.
I managed to do something similar for bar charts using plt.text, but I don't think that will be feasible with pie charts at all. I figure someone has definitely solved a similar problem before, but I can't readily fine any decent solutions.
Here is a way to move all percent-texts for patches smaller than some given amount (5 degrees in the code example). Note that this will also fail when there would be multiple small pieces close to each other.
import matplotlib.pyplot as plt
import matplotlib as mpl
import numpy as np
size_of_groups = [12, 11, 30, 0.3]
colors = ['#a1daaa', '#bbbbb4', '#444511', '#1afff2']
my_pie, texts, pct_txts = plt.pie(size_of_groups, radius=1.2, colors=colors, autopct="%.1f%%",
textprops={'color': 'w', 'size': 15}, pctdistance=0.75,
labeldistance=0.7)
labels = ['High', 'Low', 'Normal', 'NA']
plt.legend(my_pie, labels, loc='lower center', ncol=2, bbox_to_anchor=(0.5, -0.2))
plt.setp(my_pie, width=0.6, edgecolor='white')
for patch, txt in zip(my_pie, pct_txts):
if (patch.theta2 - patch.theta1) <= 5:
# the angle at which the text is normally located
angle = (patch.theta2 + patch.theta1) / 2.
# new distance to the pie center
x = patch.r * 1.2 * np.cos(angle * np.pi / 180)
y = patch.r * 1.2 * np.sin(angle * np.pi / 180)
# move text to new position
txt.set_position((x, y))
txt.set_color('black')
plt.tight_layout()
plt.show()
I attempted a solution by tweaking the solution of ImportanceOfBeingErnest on a different problem given here. For some reason, the percentage sign is not being displayed in my system but you can figure that out
rad = 1.2 # Define a radius variable for later use
my_pie, texts, autotexts = plt.pie(size_of_groups, radius=rad, colors=colors, autopct="%.1f%%",
pctdistance=0.75, labeldistance=0.7, textprops={'color':'white', 'size':20})
# Rest of the code
cx, cy = 0, 0 # Center of the pie chart
for t in autotexts:
x, y = t.get_position()
text = t.get_text()
if float(text.strip('%')) < 1: # Here 1 is the target threshold percentage
angle = np.arctan2(y-cy, x-cx)
xt, yt = 1.1*rad*np.cos(angle)+cx, 1.1*rad*np.sin(angle)+cy
t.set_color("k")
t.set_position((xt,yt))
The alignment of a text box can be specified with the horizontalalignment (ha) and verticalalignment (va) arguments, e.g.
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(8,5))
plt.subplots_adjust(right=0.5)
txt = "Test:\nthis is some text\ninside a bounding box."
fig.text(0.7, 0.5, txt, ha='left', va='center')
Which produces:
Is there anyway to keep the same bounding-box (bbox) alignment, while changing the alignment of the text within that bounding-box? e.g. for the text to be centered in the bounding-box.
(Obviously in this situation I could just replace the bounding-box, but in more complicated cases I'd like to change the text alignment independently.)
The exact bbox depends on the renderer of your specific backend. The following example preserves the x position of the text bbox. It is a bit trickier to exactly preserve both x and y:
import matplotlib
import matplotlib.pyplot as plt
def get_bbox(txt):
renderer = matplotlib.backend_bases.RendererBase()
return txt.get_window_extent(renderer)
fig, ax = plt.subplots(figsize=(8,5))
plt.subplots_adjust(right=0.5)
txt = "Test:\nthis is some text\ninside a bounding box."
text_inst = fig.text(0.7, 0.5, txt, ha='left', va='center')
bbox = get_bbox(text_inst)
bbox_fig = bbox.transformed(fig.transFigure.inverted())
print "original bbox (figure system)\t:", bbox.transformed(fig.transFigure.inverted())
# adjust horizontal alignment
text_inst.set_ha('right')
bbox_new = get_bbox(text_inst)
bbox_new_fig = bbox_new.transformed(fig.transFigure.inverted())
print "aligned bbox\t\t\t:", bbox_new_fig
# shift back manually
offset = bbox_fig.x0 - bbox_new_fig.x0
text_inst.set_x(bbox_fig.x0 + offset)
bbox_shifted = get_bbox(text_inst)
print "shifted bbox\t\t\t:", bbox_shifted.transformed(fig.transFigure.inverted())
plt.show()
I am trying to create a hexagonal grid to use with a u-matrix in Python (3.4) using a RegularPolyCollection (see code below) and have run into two problems:
The hexagonal grid is not tight. When I plot it there are empty spaces between the hexagons. I can fix this by resizing the window, but since this is not reproducible and I want all of my plots to have the same size, this is not satisfactory. But even if it were, I run into the second problem.
Either the top or right hexagons don't fit in the figure and are cropped.
I have tried a lot of things (changing figure size, subplot_adjust(), different areas, different values of d, etc.) and I am starting to get crazy! It feels like the solution should be simple, but I simply cannot find it!
import SOM
import matplotlib.pyplot as plt
from matplotlib.collections import RegularPolyCollection
import numpy as np
import matplotlib.cm as cm
from mpl_toolkits.axes_grid1 import make_axes_locatable
m = 3 # The height
n = 3 # The width
# Some maths regarding hexagon geometry
d = 10
s = d/(2*np.cos(np.pi/3))
h = s*(1+2*np.sin(np.pi/3))
r = d/2
area = 3*np.sqrt(3)*s**2/2
# The center coordinates of the hexagons are calculated.
x1 = np.array([d*x for x in range(2*n-1)])
x2 = x1 + r
x3 = x2 + r
y = np.array([h*x for x in range(2*m-1)])
c = []
for i in range(2*m-1):
if i%4 == 0:
c += [[x,y[i]] for x in x1]
if (i-1)%2 == 0:
c += [[x,y[i]] for x in x2]
if (i-2)%4 == 0:
c += [[x,y[i]] for x in x3]
c = np.array(c)
# The color of the hexagons
d_matrix = np.zeros(3*3)
# Creating the figure
fig = plt.figure(figsize=(5, 5), dpi=100)
ax = fig.add_subplot(111)
# The collection
coll = RegularPolyCollection(
numsides=6, # a hexagon
rotation=0,
sizes=(area,),
edgecolors = (0, 0, 0, 1),
array= d_matrix,
cmap = cm.gray_r,
offsets = c,
transOffset = ax.transData,
)
ax.add_collection(coll, autolim=True)
ax.axis('off')
ax.autoscale_view()
plt.show()
See this topic
Also you need to add scale on axis like
ax.axis([xmin, xmax, ymin, ymax])
The hexalattice module of python (pip install hexalattice) gives solution to both you concerns:
Grid tightness: You have full control over the hexagon border gap via the 'plotting_gap' argument.
The grid plotting takes into account the grid final size, and adds sufficient margins to avoid the crop.
Here is a code example that demonstrates the control of the gap, and correctly fits the grid into the plotting window:
from hexalattice.hexalattice import *
create_hex_grid(nx=5, ny=5, do_plot=True) # Create 5x5 grid with no gaps
create_hex_grid(nx=5, ny=5, do_plot=True, plotting_gap=0.2)
See this answer for additional usage examples, more images and links
Disclosure: the hexalattice module was written by me
Is it possible to create a RegularPolyCollection with static sizes?
I'd like to give the size in data units, not in screen units. Just like the offsetts.
The target is to have an image of a camera with 1440 hexagonal Pixels with a diameter of 9.5 mm.
It is possible to achieve this with looping over 1440 Polygons but i was not successfull creating it with a PolyCollection which has big advantages, for creating colormaps etc.
Here is the code i use to plot the 1440 hexagons with static size:
for c, x, y in zip(pixel_color, pixel_x, pixel_y):
ax.add_artist(
RegularPolygon(
xy=(x, y),
numVertices=6,
radius=4.75,
orientation=0.,
facecolor=c,
edgecolor=edgecolor,
linewidth=1.5,
)
)
And this code produces the same but with wrong and not static (in terms of data) sizes:
a = 1/np.sqrt(3) * 9.5
collection = RegularPolyCollection(
numsides=6,
rotation=0.,
sizes=np.ones(1440)*np.pi*a**2, # tarea of the surrounding circle
facecolors=pixel_colors,
edgecolors="g",
linewidth=np.ones(1440)*1.5,
offsets=np.transpose([pixel_x, pixel_y]),
transOffset=self.transData,
)
self.add_collection(collection)
How can I achieve the static sizes of the hexagons with the advantages of having a collection?
I recently had the same problem. The solution is to simply use PatchCollection instead of RegularPolyCollection. The disadvantage is, however, that you have instantiate every single patch manually. Below you'll find a code example that plots 10,000 regular hexagons on a regular grid.
# imports
import matplotlib.pyplot as plt
from matplotlib.patches import RegularPolygon
from matplotlib.collections import PatchCollection
import numpy as np
# set up figure
fig, ax = plt.subplots(1)
# positions
pixel_x, pixel_y = np.indices((100, 100))
pixel_color = np.random.random_sample(30000).reshape(10000, 3)
dx = 4 # horizontal stride
dy = 5 # vertical stride
# set static radius
poly_radius = 2.5
# list to hold patches
patch_list = []
# creat the patches
for c, x, y in zip(pixel_color, pixel_x.flat, pixel_y.flat):
patch_list.append(
RegularPolygon(
xy=(x*dy, y*dy),
numVertices=6,
radius=poly_radius,
orientation=0.,
facecolor=c,
edgecolor='k'
)
)
pc = PatchCollection(patch_list, match_original=True)
ax.add_collection(pc)
ax.axis([-3, 480, -3, 480])
plt.show()
On my machine this code takes about 2.8 seconds to render everything.
If you'd like to use RegularPolyCollection, I've figured out how to set the sizes correctly. The main limitation is that the sizes depend on the axes transform, and so both the axes limits and the figure size need to be locked in before you calculate the sizes.
In the version below, the figure - and axis - also has to be square.
import numpy as np
import matplotlib as mpl
import matplotlib.pyplot as plt
sin60 = np.sin(np.pi/3)
fig, ax = plt.subplots()
fig.set_size_inches(8, 8)
ax.set_aspect(1)
ax.set_xlim(-1.5*sin60, +1.5*sin60)
ax.set_ylim(-1.5*sin60, +1.5*sin60)
ax.set_frame_on(False)
ax.set_xticks([])
ax.set_yticks([])
coords = [[-1/2, +sin60/2], [+1/2, +sin60/2], [0, -sin60/2]]
radius = .5/sin60
data_to_pixels = ax.transData.get_matrix()[0, 0]
pixels_to_points = 1/fig.get_dpi()*72.
size = np.pi*(data_to_pixels*pixels_to_points*radius)**2
hexes = mpl.collections.RegularPolyCollection(
numsides=6,
sizes=3*(size,),
offsets=coords,
edgecolors=3*('k',),
linewidths=1,
transOffset=ax.transData)
ax.add_collection(hexes)