Matplotlib line width based on axis, not on points - python

If you set a line width in Matplotlib, you have to give the line width in points. In my case, I have two circles, both with radius R and I want to connect them with a line. I want this line to be 2*R wide in order to get a rod-shape. But when I say myLines[i].set_linewidth(2*R) this makes the lines always a specific thickness, regardless of how much I have zoomed in.
Is there a way to make lines a specific thickness not based on the number of pixels or points, but scaling with the axis? How can I make my line have the same width as the diameter of my circles?
I hope I explained myself well enough and I am looking forward to an answer.

Line in Data units
In order to draw a line with the linewidth in data units, you may want to have a look at this answer.
It uses a class data_linewidth_plot which closely resembles the plt.plot() command's signature.
l = data_linewidth_plot( x, y, ax=ax, label='some line', linewidth = 1, alpha = 0.4)
The linewidth argument is interpreted in (y-)data units.
Using this solution there is not even any need for drawing circles, since one may simply use the solid_capstyle="round" argument.
R=0.5
l = data_linewidth_plot( [0,3], [0.7,1.4], ax=ax, solid_capstyle="round",
linewidth = 2*R, alpha = 0.4)
Rod shape
A rod is much more easily produced using a rectange and two circles.

As you already figured out, linewidths are specified in axis space, not data space. To draw a line in data space, draw a rectangle instead:
import numpy as np
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle, Circle
r = 5 # rod radius
x1, y1 = (0,0) # left end of rod
x2, y2 = (10,0) # right end of rod
# create 2 circles and a joining rectangle
c1 = Circle((x1, y1), r, color='r')
c2 = Circle((x2, y2), r)
rect = Rectangle((x1, y1-r), width=x2-x1, height=2*r)
# plot artists
fig, ax = plt.subplots(1,1)
for artist in [c2, rect, c1]:
ax.add_artist(artist)
# need to set axis limits manually
ax.set_xlim(x1-r-1, x2+r+1)
ax.set_ylim(y1-r-1, y2+r+1)
# set aspect so circle don't become oval
ax.set_aspect('equal')
plt.show()

Related

How to evenly spread annotation imageboxes around a scatterplot?

I would like to annotate a scatterplot with images corresponding to each datapoint. With standard parameters the images end up clashing with each other and other important features such as legend axis, etc. Thus, I would like the images to form a circle or a rectangle around the main scatter plot.
My code looks like this for now and I am struggling to modify it to organise the images around the center point of the plot.
import matplotlib.cbook as cbook
import pandas as pd
import numpy as np
from matplotlib import pyplot as plt
from matplotlib.offsetbox import OffsetImage, AnnotationBbox
import seaborn as sns
#Generate n points around a 2d circle
def generate_circle_points(n, centre_x, center_y, radius=1):
"""Generate n points around a circle.
Args:
n (int): Number of points to generate.
centre_x (float): x-coordinate of circle centre.
center_y (float): y-coordinate of circle centre.
radius (float): Radius of circle.
Returns:
list: List of points.
"""
points = []
for i in range(n):
angle = 2 * np.pi * i / n
x = centre_x + radius * np.cos(angle)
y = center_y + radius * np.sin(angle)
points.append([x, y])
return points
fig, ax = plt.subplots(1, 1, figsize=(7.5, 7.5))
data = pd.DataFrame(data={'x': np.random.uniform(0.5, 2.5, 20),
'y': np.random.uniform(10000, 50000, 20)})
with cbook.get_sample_data('grace_hopper.jpg') as image_file:
image = plt.imread(image_file)
# Set logarithmic scale for x and y axis
ax.set(xscale="log", yscale='log')
# Add grid
ax.grid(True, which='major', ls="--", c='gray')
coordianates = generate_circle_points(n=len(data),
centre_x=0, center_y=0, radius=10)
# Plot the scatter plot
scatter = sns.scatterplot(data=data, x='x', y='y', ax=ax)
for index, row in data.iterrows():
imagebox = OffsetImage(image, zoom=0.05)
imagebox.image.axes = ax
xy = np.array([row['x'], row['y']])
xybox = np.array(coordianates[index])
ab = AnnotationBbox(imagebox, xy,
xycoords='data',
boxcoords="offset points",
xybox=xybox,
pad=0)
ax.add_artist(ab)
for the moment the output looks like this:enter image description here
Ideally I would like the output to look to something like this:
enter image description here
Many thanks in advance for your help
Not an answer but a long comment:
You can control the location of the arrows, but sometimes it is easier to export figures as SVGs and edit them in Adobe Illustrator or Inkscape.
R has a dodge argument which is really nice, but even then is not always perfect. Solutions in Python exist but are laborious.
The major issue is that this needs to be done last as alternations to the plot would make it problematic. A few points need mentioning.
Your figures will have to have a fixed size (57mm / 121mm / 184mm for Science, 83mm / 171mm for RSC, 83mm / 178mm for ACS etc.), if you need to scale the figure in Illustrator keep note of the scaling factor, adding it as a textbox outside of the canvas —as the underlying plot will need to be replaced at least once due to Murphy's law. Exporting at the right size the SVG is ideal. Sounds silly, but it helps. Likewise, make sure the font size does not go under the minimum spec (7-9 points).

Matplotlib using Wedge() in polar plots

TL/DR: How to use Wedge() in polar coordinates?
I'm generating a 2D histogram plot in polar coordinates (r, theta). At various values of r there can be different numbers of theta values (to preserve equal area sized bins). To draw the color coded bins I'm currently using pcolormesh() calls for each radial ring. This works ok, but near the center of the plot where there may be only 3 bins (each 120 degrees "wide" in theta space), pcolormesh() draws triangles that don't "sweep" out full arc (just connecting the two outer arc points with a straight line).
I've found a workaround using ax.bar() call, one for each radial ring and passing in arrays of theta values (each bin rendering as an individual bar). But when doing 90 rings with 3 to 360 theta bins in each, it's incredibly slow (minutes).
I tried using Wedge() patches, but can't get them to render correctly in the polar projection. Here is sample code showing both approaches:
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import Wedge
from matplotlib.collections import PatchCollection
# Theta coordinates in degrees
theta1=45
theta2=80
# Radius coordinates
r1 = 0.4
r2 = 0.5
# Plot using bar()
fig, ax = plt.subplots(figsize=[6,6], subplot_kw={'projection': 'polar'})
theta_mid = np.deg2rad((theta1 + theta2)/2)
theta_width = np.deg2rad(theta2 - theta1)
height = r2 - r1
ax.bar(x=theta_mid, height = height, width=theta_width, bottom=r1)
ax.set_rlim(0, 1)
plt.savefig('bar.png')
# Plot using Wedge()
fig, ax = plt.subplots(figsize=[6,6], subplot_kw={'projection': 'polar'})
patches = []
patches.append( Wedge(center=(0, 0), r = r1, theta1=theta1, theta2=theta2, width = r2-r1, color='blue'))
p = PatchCollection(patches)
ax.add_collection(p)
ax.set_rlim(0, 1)
plt.savefig('wedge.png')
The outputs of each are:
Bar
Wedge
I've tried using radians for the wedge (because polar plots usually want their angle values in radians). That didn't help.
Am I missing something in how I'm using the Wedge? If I add thousands of Wedges to my Patch collection should I have any expectation it will be faster than bar()?
Thinking this was an actual bug, I opened this issue https://github.com/matplotlib/matplotlib/issues/22717 on matplotlib where one of the maintainers nicely pointed out that I should be using Rectangle() instead of Wedge().
The solution they provided is
from matplotlib.patches import Rectangle
fig, ax = plt.subplots(figsize=[6,6], subplot_kw={'projection': 'polar'})
p = PatchCollection([Rectangle((np.deg2rad(theta1), r1), theta_width, height, color='blue')])
ax.add_collection(p)
ax.set_rlim(0, 1)
plt.savefig('wedge.png')

How to change color of particular edge for polygon in python?

There is a polygon and I am wondering how can I change color of particular edge of it like figure below.
import matplotlib.pyplot as plt
import numpy as np
## -----------------------Initialize Geometry-----------------------
pixels = 600
my_dpi = 100
coord = [[-150,-200],[300,-200],[300,0],[150,200],[-150,200]]
fig = plt.figure(figsize=( pixels/my_dpi, pixels/my_dpi), dpi=my_dpi)
plt.axes([0,0,1,1])
rectangle = plt.Rectangle((-300, -300), 600, 600, fc='k')
plt.gca().add_patch(rectangle)
polygon = plt.Polygon(coord,fc='w')
plt.gca().add_patch(polygon)
plt.axis('off')
plt.axis([-300,300,-300,300])
plt.savefig('figure1/5.jpg',dpi=my_dpi)
The easiest way to do this would be to simply plot a line between the two relevant vertices of the polygon, i.e.
plt.plot([coords[0,0], coords[-1,0]], [coords[0,1], coords[-1,1]], color='r', lw=5)
Would give you
Although I recommend adding a border to the polygon with the same width as this line of the same color as the facecolor:
polygon = plt.Polygon(coord,fc='w',ec='w',lw=5)
As a way to make the red line appear flush. You can change which edge is colored you simply change the indices of coords[i,j] in plt.plot() and as long as the indices are adjacent (with wrapping - so last index and first index are adjacent) the line drawn will be an edge and not a diagonal.
Also note you can shorten the plotting command by using slices or a helper function but I have neglected this for the sake of being explicit.

Making a scatter marker as large as the Axes

Im trying to scatter a single (square) marker such that it fills the whole figure (no more, no less).
As for simplification, I'm creating a figure such that x- and y- axes both go from -0.5 to 0.5. That is, the plotting area is the unit square, centred at the origin.
The marker now shall be scattered at the origin. What size should it be so that it occupies exactly the unit square?
I looked at this Finding the right marker size for a scatter plot and this pyplot scatter plot marker size but couldn't get it right so far.
This is what I tried:
fig, ax = plt.subplots(figsize=(4,4));
ax.set_aspect('equal');
ax.set_xlim(-0.5, 0.5);
ax.set_ylim(-0.5, 0.5);
figsize = fig.get_size_inches()[0]
dpi = fig.dpi
print(f'figsize = {int(figsize)}')
print(f'dpi = {int(dpi)}')
print(f'figure is {int(figsize*dpi)} x {int(figsize*dpi)} pixels\n')
print(f'setting the marker size to be {int(figsize*dpi)}**2 = {int((figsize*dpi)**2)}')
ax.scatter(0, 0, s=(figsize*dpi)**2, marker='s');
It turns out that the marker (blue area) does fill the unit square but it is actually filling way more than that. After manually trying different sizes, the right value seems to be around 46000 (opposed to the 82944 suggested at the second post).
You will need to apply the aspect, then get the axes width and transform it to display space (or transform the axes position first, then get its width). This can be used to calculate the width of the axes in units of points.
The square of that number is the markersize of the scatter if it shall be as large as the axes.
import matplotlib.pyplot as plt
fig, ax = plt.subplots(figsize=(4,4))
ax.set_xlim(-0.5, 0.5)
ax.set_ylim(-0.5, 0.5)
ax.set_aspect('equal')
ax.apply_aspect()
s = ax.get_position().transformed(fig.transFigure).width*72/fig.dpi
ax.scatter(0, 0, s=s**2, marker='s');
plt.show()

Expand the line with specified width in data unit

My question is a bit similar to this question that draws line with width given in data coordinates. What makes my question a bit more challenging is that unlike the linked question, the segment that I wish to expand is of a random orientation.
Let's say if the line segment goes from (0, 10) to (10, 10), and I wish to expand it to a width of 6. Then it is simply
x = [0, 10]
y = [10, 10]
ax.fill_between(x, y - 3, y + 3)
However, my line segment is of random orientation. That is, it is not necessarily along x-axis or y-axis. It has a certain slope.
A line segment s is defined as a list of its starting and ending points: [(x1, y1), (x2, y2)].
Now I wish to expand the line segment to a certain width w. The solution is expected to work for a line segment in any orientation. How to do this?
plt.plot(x, y, linewidth=6.0) cannot do the trick, because I want my width to be in the same unit as my data.
The following code is a generic example on how to make a line plot in matplotlib using data coordinates as linewidth. There are two solutions; one using callbacks, one using subclassing Line2D.
Using callbacks.
It is implemted as a class data_linewidth_plot that can be called with a signature pretty close the the normal plt.plot command,
l = data_linewidth_plot(x, y, ax=ax, label='some line', linewidth=1, alpha=0.4)
where ax is the axes to plot to. The ax argument can be omitted, when only one subplot exists in the figure. The linewidth argument is interpreted in (y-)data units.
Further features:
It's independend on the subplot placements, margins or figure size.
If the aspect ratio is unequal, it uses y data coordinates as the linewidth.
It also takes care that the legend handle is correctly set (we may want to have a huge line in the plot, but certainly not in the legend).
It is compatible with changes to the figure size, zoom or pan events, as it takes care of resizing the linewidth on such events.
Here is the complete code.
import matplotlib.pyplot as plt
class data_linewidth_plot():
def __init__(self, x, y, **kwargs):
self.ax = kwargs.pop("ax", plt.gca())
self.fig = self.ax.get_figure()
self.lw_data = kwargs.pop("linewidth", 1)
self.lw = 1
self.fig.canvas.draw()
self.ppd = 72./self.fig.dpi
self.trans = self.ax.transData.transform
self.linehandle, = self.ax.plot([],[],**kwargs)
if "label" in kwargs: kwargs.pop("label")
self.line, = self.ax.plot(x, y, **kwargs)
self.line.set_color(self.linehandle.get_color())
self._resize()
self.cid = self.fig.canvas.mpl_connect('draw_event', self._resize)
def _resize(self, event=None):
lw = ((self.trans((1, self.lw_data))-self.trans((0, 0)))*self.ppd)[1]
if lw != self.lw:
self.line.set_linewidth(lw)
self.lw = lw
self._redraw_later()
def _redraw_later(self):
self.timer = self.fig.canvas.new_timer(interval=10)
self.timer.single_shot = True
self.timer.add_callback(lambda : self.fig.canvas.draw_idle())
self.timer.start()
fig1, ax1 = plt.subplots()
#ax.set_aspect('equal') #<-not necessary
ax1.set_ylim(0,3)
x = [0,1,2,3]
y = [1,1,2,2]
# plot a line, with 'linewidth' in (y-)data coordinates.
l = data_linewidth_plot(x, y, ax=ax1, label='some 1 data unit wide line',
linewidth=1, alpha=0.4)
plt.legend() # <- legend possible
plt.show()
(I updated the code to use a timer to redraw the canvas, due to this issue)
Subclassing Line2D
The above solution has some drawbacks. It requires a timer and callbacks to update itself on changing axis limits or figure size. The following is a solution without such needs. It will use a dynamic property to always calculate the linewidth in points from the desired linewidth in data coordinates on the fly. It is much shorter than the above.
A drawback here is that a legend needs to be created manually via a proxyartist.
import matplotlib.pyplot as plt
from matplotlib.lines import Line2D
class LineDataUnits(Line2D):
def __init__(self, *args, **kwargs):
_lw_data = kwargs.pop("linewidth", 1)
super().__init__(*args, **kwargs)
self._lw_data = _lw_data
def _get_lw(self):
if self.axes is not None:
ppd = 72./self.axes.figure.dpi
trans = self.axes.transData.transform
return ((trans((1, self._lw_data))-trans((0, 0)))*ppd)[1]
else:
return 1
def _set_lw(self, lw):
self._lw_data = lw
_linewidth = property(_get_lw, _set_lw)
fig, ax = plt.subplots()
#ax.set_aspect('equal') # <-not necessary, if not given, y data is assumed
ax.set_xlim(0,3)
ax.set_ylim(0,3)
x = [0,1,2,3]
y = [1,1,2,2]
line = LineDataUnits(x, y, linewidth=1, alpha=0.4)
ax.add_line(line)
ax.legend([Line2D([],[], linewidth=3, alpha=0.4)],
['some 1 data unit wide line']) # <- legend possible via proxy artist
plt.show()
Just to add to the previous answer (can't comment yet), here's a function that automates this process without the need for equal axes or the heuristic value of 0.8 for labels. The data limits and size of the axis need to be fixed and not changed after this function is called.
def linewidth_from_data_units(linewidth, axis, reference='y'):
"""
Convert a linewidth in data units to linewidth in points.
Parameters
----------
linewidth: float
Linewidth in data units of the respective reference-axis
axis: matplotlib axis
The axis which is used to extract the relevant transformation
data (data limits and size must not change afterwards)
reference: string
The axis that is taken as a reference for the data width.
Possible values: 'x' and 'y'. Defaults to 'y'.
Returns
-------
linewidth: float
Linewidth in points
"""
fig = axis.get_figure()
if reference == 'x':
length = fig.bbox_inches.width * axis.get_position().width
value_range = np.diff(axis.get_xlim())
elif reference == 'y':
length = fig.bbox_inches.height * axis.get_position().height
value_range = np.diff(axis.get_ylim())
# Convert length to points
length *= 72
# Scale linewidth to value range
return linewidth * (length / value_range)
Explanation:
Set up the figure with a known height and make the scale of the two axes equal (or else the idea of "data coordinates" does not apply). Make sure the proportions of the figure match the expected proportions of the x and y axes.
Compute the height of the whole figure point_hei (including margins) in units of points by multiplying inches by 72
Manually assign the y-axis range yrange (You could do this by plotting a "dummy" series first and then querying the plot axis to get the lower and upper y limits.)
Provide the width of the line that you would like in data units linewid
Calculate what those units would be in points pointlinewid while adjusting for the margins. In a single-frame plot, the plot is 80% of the full image height.
Plot the lines, using a capstyle that does not pad the ends of the line (has a big effect at these large line sizes)
Voilà? (Note: this should generate the proper image in the saved file, but no guarantees if you resize a plot window.)
import matplotlib.pyplot as plt
rez=600
wid=8.0 # Must be proportional to x and y limits below
hei=6.0
fig = plt.figure(1, figsize=(wid, hei))
sp = fig.add_subplot(111)
# # plt.figure.tight_layout()
# fig.set_autoscaley_on(False)
sp.set_xlim([0,4000])
sp.set_ylim([0,3000])
plt.axes().set_aspect('equal')
# line is in points: 72 points per inch
point_hei=hei*72
xval=[100,1300,2200,3000,3900]
yval=[10,200,2500,1750,1750]
x1,x2,y1,y2 = plt.axis()
yrange = y2 - y1
# print yrange
linewid = 500 # in data units
# For the calculation below, you have to adjust width by 0.8
# because the top and bottom 10% of the figure are labels & axis
pointlinewid = (linewid * (point_hei/yrange)) * 0.8 # corresponding width in pts
plt.plot(xval,yval,linewidth = pointlinewid,color="blue",solid_capstyle="butt")
# just for fun, plot the half-width line on top of it
plt.plot(xval,yval,linewidth = pointlinewid/2,color="red",solid_capstyle="butt")
plt.savefig('mymatplot2.png',dpi=rez)

Categories

Resources