Matplotlib, Inkscape, Spyder, plots and SVG compatibility (true axis size) - matplotlib

I have been plotting data for years during my PhD and always had to fight with something that unfortunately plagues the scientific community: negligent data manipulation.
My problem is that when I plot with matplotlib two graphics with different number lengths in the Y axis, the result is two graphics with two different X axis sizes.
When I copy the resulting SVG image directly from Spyder IPython console (Copy SVG) and paste in Inkscape for editing, matching the axis is a painful task which requires scaling them correctly with absolute precision. I am aware there plugins that are able to rescale plots in Inkscape and etc.

Bonus solved problem 1: for some reason, the size of an SVG created by matplotlib is scaled by 0.75 relative to Inkscape
Bonus solved problem 2: Matplotlib uses... inches, so the 25.4 that is in the following code lines is simply to convert from inch to millimeters.
Sometimes, having more control at the root is better than patching and patching and patching. So here is my solution to those who have been agonizing like me over being able to have two plots with the same absolute axis sizes:
from matplotlib import pyplot as plt
inch = False # Set to True if you want to use inch (blergh...).
width = 50 # The actual size in millimeters for the X axis to have.
height = 20 # The actual size in millimeters for the Y axis to have.
figsize = [(-0.212+width)/(1+24.4*(not inch)),(-0.212+height)/(1+24.4*(not inch))] # [W, H]
# Attention to the 0.212 mm which is thickness of the axis line; the cap at the end of the axis is half of thickness and is accounted for the size of the axis in Inkscape. So, when you use the size of a line from Inkscape as the desired size of the axis in a plot from matplotlib, ax.get_linewidth() by default should be 0.8 (whatever 0.8 is.. but it seems like 0.212/25.4 * 100).
height_scale = 3 # Scale to account for the axis title, labels and ticks.
width_scale = 2 # Scale to account for the axis title, labels and ticks.
figsize = [width_scale*figsize[0]/0.75, height_scale*figsize[1]/0.75]
fig = plt.figure(figsize = (figsize[0], figsize[1]))
wpos = (50/(1+24.4*(not inch)))/(figsize[0]/0.75) # Giving 50 mm mandatory position shift for the Y axis, to accommodate the title, labels and ticks.
hpos = (40/(1+24.4*(not inch)))/(figsize[1]/0.75) # Giving 40 mm mandatory position shift for the X axis to accommodate the title, labels and ticks.
# Now comes the problem. The AXIS size is defined relatively to the FIGURE size. The following values will simply use the rescaled FIGURE sizes:
wscale = 1/width_scale # = (width_scale*figsize[0]/0.75)/width_scale = figsize[0]/0.75 which is our target size for Inkscape.
hscale = 1/height_scale
ax = fig.add_axes([wpos, hpos, wscale, hscale])
Then you can plot at will, copy the SVG output (in Spyder's IPython console, at least) and paste it in Inkscape.
The only set back is that the whole FIGURE size will be abnormal and you'll have to remove the white background from it in Inkscape. But that is something probably all of us already do.
This is a minimal working code. You can paste it in your IPython console and copy the SVG output, paste it in Inkscape and check the axis line size. It will be with a width of 50 mm and a height of 20 mm.

Related

Plotting xarray.DataArray and Geopandas together - aspect ratio errors

I am trying to create two images side by side: one satellite image alone, and next to it, the same satellite image with outlines of agricultural fields. My raster data "raster_clip" is loaded into rioxarray (original satellite image from NAIP, converted from .sid to .tif), and my vector data "ag_clip" is in geopandas. My code is as follows:
fig, (ax1, ax2) = plt.subplots(ncols = 2, figsize=(14,8))
raster_clip.plot.imshow(ax=ax1)
raster_clip.plot.imshow(ax=ax2)
ag_clip.boundary.plot(ax=ax1, color="yellow")
I can't seem to figure out how to get the y axes in each plot to be the same. When the vector data is excluded, then the two plots end up the same shape and size.
I have tried the following:
Setting sharey=True in the subplots method. Doesn't affect shape of resulting images, just removes the tic labels on the second image.
Setting "aspect='equal'" in the imshow method, leads to an error, which doesn't make sense because the 'aspect' kwarg is listed in the documentation for xarray.plot.imshow.
plt.imshow's 'aspect' kwarg is not available in xarray
Removing the "figsize" variable, doesn't affect the ratio of the two plots.
not entirely related to your question but i've used cartopy before for overlaying a GeoDataFrame to a DataArray
plt.figure(figsize=(16, 8))
ax = plt.subplot(projection=ccrs.PlateCarree())
ds.plot(ax=ax)
gdf.plot(ax=ax)

Is it possible to break x and y axis at the same time on lineplot?

I am working on drawing lineplots with matplotlib.
I checked several posts and could understand how the line break works on matplotlib (Break // in x axis of matplotlib)
However, I was wondering is it possible to break x and y axis all together at the same time.
My current drawing looks like below.
As shown on the graph, x-axis [2000,5000] waste spaces a lot.
Because I have more data that need to be drawn after 7000, I want to save more space.
Is it possible to split x-axis together with y-axis?
Or is there another convenient way to not to show specific region on lineplot?
If there is another library enabling this, I am willing to drop matplotlib and adopt others...
Maybe splitting the axis isn't your best choice. I would perhaps try inserting another smaller figure into the open space of your large figure using add_axes(). Here is a small example.
t = np.linspace(0, 5000, 1000) # create 1000 time stamps
data = 5*t*np.exp(-t/100) # and some fake data
fig, ax = plt.subplots()
ax.plot(t, data)
box = ax.get_position()
width = box.width*0.6
height = box.height*0.6
x = 0.35
y = 0.35
subax = fig.add_axes([x,y,width,height])
subax.plot(t, data)
subax.axis([0, np.max(t)/10, 0, np.max(data)*1.1])
plt.show()

Dynamically scaling axes during a matplotlib ArtistAnimation

It appears to be impossible to change the y and x axis view limits during an ArtistAnimation, and have the frames replayed with different axis limits.
The limits seem to fixed to those set last before the animation function is called.
In the code below, I have two plotting stages. The input data in the second plot is a much smaller subset of the data in the 1st frame. The data in the 1st stage has a much wider range.
So, I need to "zoom in" when displaying the second plot (otherwise the plot would be very tiny if the axis limits remain the same).
The two plots are overlaid on two different images (that are of the same size, but different content).
import matplotlib
import matplotlib.pyplot as plt
import matplotlib.animation as animation
import matplotlib.image as mpimg
import random
# sample 640x480 image. Actual frame loops through
# many different images, but of same size
image = mpimg.imread('image_demo.png')
fig = plt.figure()
plt.axis('off')
ax = fig.gca()
artists = []
def plot_stage_1():
# both x, y axis limits automatically set to 0 - 100
# when we call ax.imshow with this extent
im_extent = (0, 100, 0, 100) # (xmin, xmax, ymin, ymax)
im = ax.imshow(image, extent=im_extent, animated=True)
# y axis is a list of 100 random numbers between 0 and 100
p, = ax.plot(range(100), random.choices(range(100), k=100))
# Text label at 90, 90
t = ax.text(im_extent[1]*0.9, im_extent[3]*0.9, "Frame 1")
artists.append([im, t, p])
def plot_stage_2():
# axes remain at the the 0 - 100 limit from the previous
# imshow extent so both the background image and plot are tiny
im_extent = (0, 10, 0, 10)
# so let's update the x, y axis limits
ax.set_xlim(im_extent[0], im_extent[1])
ax.set_ylim(im_extent[0], im_extent[3])
im = ax.imshow(image, extent=im_extent, animated=True)
p, = ax.plot(range(10), random.choices(range(10), k=10))
# Text label at 9, 9
t = ax.text(im_extent[1]*0.9, im_extent[3]*0.9, "Frame 2")
artists.append([im, t, p])
plot_stage_1()
plot_stage_2()
# clear white space around plot
fig.subplots_adjust(left=0, bottom=0, right=1, top=1, wspace=None, hspace=None)
# set figure size
fig.set_size_inches(6.67, 5.0, True)
anim = animation.ArtistAnimation(fig, artists, interval=2000, repeat=False, blit=False)
plt.show()
If I call just one of the two functions above, the plot is fine. However, if I call both, the axis limits in both frames will be 0 - 10, 0 - 10. So frame 1 will be super zoomed in.
Also calling ax.set_xlim(0, 100), ax.set_ylim(0, 100) in plot_stage_1() doesn't help. The last set_xlim(), set_ylim() calls fix the axis limits throughout all frames in the animation.
I could keep the axis bounds fixed and apply a scaling function to the input data.
However, I'm curious to know whether I can simply change the axis limits -- my code will be better this way, because the actual code is complicated with multiple stages, zooming plots across many different ranges.
Or perhaps I have to rejig my code to use FuncAnimation, instead of ArtistAnimation?
FuncAnimation appears to result in the expected behavior. So I'm changing my code to use that instead of ArtistAnimation.
Still curious to know though, whether this can at all be done using ArtistAnimation.

Colorbar frame and color not aligned

I have a vexing issue with a colorbar and even after vigorous research I cannot find the question even being asked. I have a plot where I overlay a contour and a pcolormesh and I would like a colorbar to indicate values. That works fine except for one thing:
The colorbar frame and color are offset
The colorbar frame and the actual bar are offset such that below you have a white bit in the frame and on top the color is poking out. While the frame is aligned with the axis as desired, the colorbar is offset.
Here is a working example that emulates the situation I was in, i.e. multiple plots with insets.
import matplotlib.gridspec as gridspec
import numpy as np
import matplotlib
import matplotlib.pyplot as plt
figheight = 4.2 - (2.1 - 49.519 / 25.4)
matplotlib.rcParams['figure.figsize'] = (5.25, figheight)
matplotlib.rcParams['axes.linewidth'] = 0.5
fig = plt.figure()
grid = gridspec.GridSpec(2, 1, height_ratios=[49.519 / 25.4 / figheight, 2.1 / figheight])
ax0 = plt.subplot(grid[0, 0])
ax1 = plt.subplot(grid[1, 0])
plt.tight_layout()
###############################################################################################
#
# Define position of inset
#
###############################################################################################
ax1.axis('off')
pos1 = ax1.get_position()
pos2 = matplotlib.transforms.Bbox([[pos1.x0, pos1.y0],
[.8*pos1.x1,
0.8*pos1.height + pos1.y0]])
left, bottom, width, height = [pos2.x0, pos2.y0, pos2.width, pos2.height]
ax2 = fig.add_axes([left, bottom, width, height])
###############################################################################################
#
# ax2 (inset) plot
#
###############################################################################################
pos2 = ax2.get_position()
ax2.axis('on')
x = np.linspace(0,5)
z = (np.outer(np.sin(x), np.cos(x))+1)*0.5
im = ax2.pcolormesh(z)
c = ax2.contour(z, linewidths=7)
ax2pos = ax2.get_position()
cbar_axis = fig.add_axes([ax2pos.x1+0.05,ax2pos.y0, .02, ax2pos.height])
colorbar = fig.colorbar(im, ax = ax2,
cax = cbar_axis, ticks = [0.1, .5, .9])
colorbar.outline.set_visible(True)
plot = 'Minimal.pdf'
fig.savefig(plot)
plt.close()
The problem persists in both the inline display and the saved .pdf if 'Inline' graphics backend is chosen. Using tight layout or not changes how badly the offset is depending on the size of the bar - same with using PyQT5 rather than inline graphics backend. I thought it was gone when I was changing between the various combinations, but I just realized it's still there.
I would appreciate any input.
As suggested by ImportanceOfBeingErnest I have tried using np.round on the figsize and that didn't change things. While you can fiddle around with sizes to make it look okay, it always stands over on one or the other side by some amount. When I change the graphics backend on Spyder 3 from 'Inline' to 'QT5' the problem becomes less severe with or without rounding. A summary of this is in this picture Colorbar overlap cases. Note that with not rounded and PyQT5 the problem still occurs, but is not as severe.
On inspection, it is clear that the colorbar is not only bleeding out over the top of its axes, but it's also positioned slightly to the left.
So, the problem here appears to be a conflict between the position of the colorbar axis and the colorbar itself when rasterization occurs. You can find more details on this issue in matplotlib's github repository, but I'll summarize what's going on here.
Colorbars are rasterized when the output is produced, so as to avoid artifacting issues during rendering. The position of the colorbar is snapped to the nearest integer pixels during the rasterization process, while the axis is kept where it is supposed to be. Then, when the output is produced, the colorbar falls within borders of fixed pixels of the image, despite the fact that the image is, itself, vectorized. Thus, there are two strategies that can be employed to avoid this mishap.
Use a finer DPI
The conversion from vectorized coordinates to rasterized coordinates takes place assuming a given DPI on the image. By default, this is set to be 72. However, by using more DPI, the overall shift induced by the rasterization process will be smaller, as the closest pixel the colorbar will snap to will be much nearer. Here, we change the output to have fig.savefig(plot,dpi=4000), and the problem goes away:
Note, however, that on my machine, the output size changed from 62 KB to 78 KB due to this change (although the DPI adjustment was also, admittedly, extreme). If you are worried about file sizes, you should pick a lower DPI that fixes the problem.
Use a different colormap
This rasterization happens when more than 50 colors are in the colorbar. Thus, we can do a quick test, setting our colormap to Pastel1 via
im = ax2.pcolormesh(z,cmap='Pastel1'). Here, the colorbar / axis mismatch is mitigated.
As a fallback, adopting a colorbar with fewer than 50 colors should mitigate this problem.
Rasterize the Axis
For completeness, there is also a third option. If you rasterize the colorbar axis, both the axis boundaries and the colormap will be rasterized, and you'll lose the offset. This will also rasterize your labels, and the axis will shift as one, breaking alignment with the nearby axis. For this, you just need to include cbar_axis.set_rasterized(True).
First, a way to overlay a contour and a pcolormesh and create a colorbar would be the following
import matplotlib.pyplot as plt
from mpl_toolkits.axes_grid1 import make_axes_locatable
import numpy as np
x = np.linspace(0,5)
z = (np.outer(np.sin(x), np.cos(x))+1)*0.5
fig = plt.figure(figsize=(4, 4))
ax = fig.add_subplot(111)
im = ax.pcolormesh(z)
c = ax.contour(z, linewidths=7)
divider = make_axes_locatable(ax)
cax = divider.append_axes("right", "5%", pad="3%")
colorbar = fig.colorbar(im, cax=cax, ticks = [0.1, .5, .9])
plt.show()
Now to the problem from the question. It is of course possible to create the axes to put the colorbar in manually. Replacing the colorbar creation with the code from the question still produces a nice image.
import matplotlib.pyplot as plt
import numpy as np
x = np.linspace(0,5)
z = (np.outer(np.sin(x), np.cos(x))+1)*0.5
fig = plt.figure(figsize=(4, 4))
ax = fig.add_subplot(111)
plt.subplots_adjust(right=0.8)
im = ax.pcolormesh(z)
c = ax.contour(z, linewidths=7)
ax2pos = ax.get_position()
cbar_axis = fig.add_axes([ax2pos.x1+0.05,ax2pos.y0, .05, ax2pos.height])
colorbar = fig.colorbar(im, ax = ax,
cax = cbar_axis, ticks = [0.1, .5, .9])
colorbar.outline.set_visible(True)
plt.show()
Conclusion so far: The issue is not reproducible, at least not without a Minimal, Complete, and Verifiable example.
I'm uncertain about the reasons for the behaviour in the example from the question. However, it seems that it can be overcome by rounding the figure size to 3 significant digits
matplotlib.rcParams['figure.figsize'] = (5.25, np.round(figheight,3))

Pixel manipulation by python matplotlib

I found the python matplotlib function very useful. I would be much better for image processing if each individual pixel is fixed.
I would like to do pixel by pixel processing. The problem is, after using the "handles.append" function, the image margin changes.
from skimage.io import imread
import matplotlib.pyplot as plt
img = imread('uk_figure.png')
my_dpi = 96
plt.figure(figsize=(800/my_dpi, 800/my_dpi), dpi = my_dpi, frameon=False)
fig1 = plt.figure(1)
fig1.set_figheight(40) #image size is set here, pixel = 8 *100 = 800
fig1.set_figwidth(40) #image size is set here, pixel = 8 *100 = 800
imgplot = plt.imshow(img)
#to add code below
plt.axis("off")
plt.subplots_adjust(left = 0, right =1, top =1, bottom =0)
plt.savefig("uk_figure_addcolor4.png", pad_inches=0)
The figure saved is EXACTLY the same as original image. Thus, I think I could process the two image by pixel by pixel processing.
After added this code, matplotlib automatically added a margin for my image. Thus, pixel by pixel processing fails. Is there a way to instruct matplotlib to save image with fixed pixel?
x = [457,458,459,460]
y = [288,289,290,291]
handles = []
handles.append(plt.scatter(x,y, color='blue', marker='+')) #margin changes after adding this line
The margin is added due to a change in the axis-limits. If your image is 1000 by 1000 pixels big say, you can solve your problem by adding plt.gca().set_xlim(0, 1000) and plt.gca().set_ylim(0, 1000). plt.gca() will get you the current axis, and then to that axis set the x- and y-limits.
Note: You might need to invert the y-limits to plt.gca().set_ylim(1000, 0), since matplotlib usually chooses the upper left corner as the origin for images. I used random data so its hard to tell if the image is flipped or not, but if you have a motive in your image you can very easy tell if the image is flipped or not. If it is flipped, invert the ylim as above.