While working on the Spirograph series of posts, I went through a few phases of development and/or artistic endeavour. At first I was just happy to generate a plot on a white background minus all the axis labels, borders, ticks, etc. Though I was using Matplotlib, that was pretty straightforward.
But when I decided I’d like coloured backgrounds, my initial approach had some issues. So, I had to change things a bit. Then it occurred to me that I’d like to get some of the images Giclée printed and framed. Now I had to consider not just the final size of the canvas, but I had to control the size of the image so that the frame matting wouldn’t cover any of it. But, I also had to make sure the background ran under the matting. I sorted an approach, but it never really appealed to me. A little to convoluted and unpredictable.
Then there was the aspect ratio. I wanted for the most part to generate a square (aspect='equal'
) image. But I didn’t really understand what adjustable='datalim'
did versus what adjustable='box'
did. And no easy way to see two images side by each. Then there was the sizing thing. Very difficult to do for some of the image types if autoscale
was disabled. And, if autoscale
was enabled when I tried setting the larger background ‘image’ things often didn’t go the way I wanted or expected.
And, of course, with all those changes I couldn’t replicate older images I really liked. In one particular case, that really bothers me. I may waste a bunch of time trying to sort that out. And, because so many values used in generating the images were intialized randomly
, it really is/was difficult to reproduce the more complex images. Another thing I may yet waste a bunch of time on.
I have recently settled on a, perhaps, final approach to generating the desired images. Don’t yet know if it will make reproducing images more feasible or not. But, for those of you who might want to generate digital images using Matplotlib, I thought I would document some of what I went through. Well, assuming I can recreate or recall the various steps. Hopefully git will help. But I often committed rather large changes which might inhibit the recovery of past techniques.
Start Simple
So I instantiate figure and axes objects with the subplots
method. I originally started with 8 in by 8 in images. Enough for my purposes at the time. Then I turn off all the axis stuff. Then I make sure the image is using all the avaialble space. Finally set the aspect ratio to square. And there you go, pretty much what I wanted.
# Set up plot/figure
fig, ax = plt.subplots(figsize=(8,8))
plt.axis('off')
# use all available space
ax.set_position([0, 0, 1, 1], which='both')
# set the aspect ratio to square
ax.set_aspect('equal')
Examples
Examples of the minimalist style of the generated images.
Coloured Backgrounds
Gradient
I borrowed a function that generated a gradient background using imshow()
. I continued using the code from above to set up my plotting/image figure and axes. Then after plotting the curve, I called the function to paint the background.
x_mn, x_mx = ax.get_xlim()
y_mn, y_mx = ax.get_ylim()
ax = gradient_image(ax, extent=(x_mn, x_mx, y_mn, y_mx), direction=0.3, cmap_range=(0, 1), cmap=clr_nm[rcm], alpha=bg_lpha)
All too often the result was not what I was expecting. Almost every image had at least a thin white border on the left/right or top/bottom. No idea why. For example!
I verified that the axes limits and plot limits were the same.
I first tried changing the call to set_aspect
to use adjustable='datalim'
. No change. So I tried, adjustable='box'
.
Until I tried generating images a number of more times. So, afterall, no success!
So, I decided to go back to basics and just use a single background colour implemented with Matplotlib’s set_facecolor()
method.
Single Colour
Okay, always go back to basics when troubleshooting a problem. Use a single colour for the background, taken from the current colour map in the cycler. Needless to say, there is some code you are not seeing.
I added this code after I plotted the curve. The order really doesn’t matter. It was just as good a spot as any.
# for some reason I chose only use the first 25% of the available colours in the current colour map
n_clr = int(lc_frq / 4)
c_bg = cycle[np.random.randint(0, lc_frq)]
# select opacity between 0.35 and 0.66 (inclusive?)
bg_lpha = np.random.randint(35, 66) / 100
ax.set_facecolor(c_bg)
ax.patch.set_alpha(bg_lpha)
For the images I have been generating I really didn’t want all the axes stuff included. So, as shown above, I turned them off with plt.axis('off')
. Well, that can be problematic.
The background patch is part of the axes. So if the axes is turned off, so will the background patch.
- Turning axes off and setting facecolor at the same time not possible?
That article provided 3 potential solutions. After playing with the one that added a new patch, I decided to use the one that removed the axis spines and ticks from the plot. So, I replaced plt.axis('off')
with the suggested code.
# Set up plot/figure
fig, ax = plt.subplots(figsize=(8,8))
for spine in ax.spines.values():
spine.set_visible(False)
ax.tick_params(bottom=False, labelbottom=False, left=False, labelleft=False) if do_ttl:
ax.set_position([0, 0, .9, 1], which='both')
else:
ax.set_position([0, 0, 1, 1], which='both')
ax.set_aspect('equal', adjustable='box')
With adjustable='box'
I was still getting white borders of various sizes on either the sides or the top and bottom. Changing that to adjustable='datalim'
seemed to fix things. Though I sure don’t understand why!! But at least I was getting a background colour.
Did That Fix Gradient/Blotchty Background
Multiple code executions with a single background colour, did not show the white border issue. As soon as I went to the gradient or blotchy background functions, the borders re-appeared on many images. Those two functions use imshow()
. Wonder if that has something to do with it.
So, I decided to mess with extent=
in the call to the gradient or blotchy background functions. Which is passed directly to imshow()
. I decided on a simple algorithm to get the extents for the background.
First of all I set the plot aspect to auto
before plotting the curve and the background. I am plotting the background after the curve so that I have some meaningful data limits to use for the background. I then replot the curve after the background is drawn so that it is drawn on top of the background. And, finally I set the aspect to equal
.
plt.plot(np.real(r_pts[-1]), np.imag(r_pts[-1]), lw=ln_w, ls=ln_s, alpha=c_lpha)
x_mn, x_mx = ax.get_xlim()
y_mn, y_mx = ax.get_ylim()
if y_mn < x_mn:
l_mn = y_mn
else:
l_mn = x_mn
if y_mx > x_mx:
l_mx = y_mx
else:
l_mx = x_mx
bax = gradient_image(ax, extent=(l_mn, l_mx, l_mn, l_mx), direction=0.3, cmap_range=(0, 1), cmap=clr_nm[rcm], alpha=bg_lpha)
plt.plot(np.real(r_pts[-1]), np.imag(r_pts[-1]), lw=ln_w, ls=ln_s, alpha=c_lpha)
ax.set_aspect('equal', adjustable='datalim')
After a number of repeated executions with gradient and blotchy backgrounds, I was no longer getting any unwanted white space in the images.
And, it seems to work for the gnarly plot style as well. Sorry about the colour similarity in the two images.
Generate Images for Framing
At the moment I must admit I am not sufficiently informed about getting images Giclée printed and framed. I am looking at finished frame sizes of 25x25 cm to 36x36 cm. Since, matplotlib by default sets image sizes in inches that would be 10x10 to 14x14 inches. I wrote a bit of code to figure out the size of the matting and consequently the size of the area that will be visible (my curve plotting region). I also figured I might save a few pennies by not printing the whole of the frame size. Figured I just needed enough background to make sure it extended under tha mat.
I have been setting the figure size to 8x8 inches. I will for now work with 14x14 inches.
fig_sz = 14
...
# sort axes position
wdv = .715 # golden ratio
# allow 5% all sides for background
wdv2 = wdv * 1.10
llv2 = (1 - wdv2) / 2
# Set up plot/figure
fig, ax = plt.subplots(figsize=(fig_sz,fig_sz))
for spine in ax.spines.values():
spine.set_visible(False)
ax.tick_params(bottom=False, labelbottom=False, left=False, labelleft=False)
if do_ttl:
ax.set_position([0, 0, .9, 1], which='both')
else:
ax.set_position([llv2, llv2, wdv2, wdv2], which='both')
...
And, based on some testing, that seems to work reasonably well. But, when it came to images where the x and y limits were significantly different, the image gets squashed in one direction or the other.
I really would like the curves to fill as much of the visible drawing area as possible.
Multiple Artists
I won’t go through all the other minor variations on the above that I attempted. None were particularly successful at resolving all my concerns.
I am not sure that I am using the Matplotlib terminology correctly. But I recalled reading that one could create multiple artists on a Matplotlib figure
. So, I figured what if create one axes for the background, of whatever size I wished. And, a second, smaller one for the curve. I would also have to make sure that they are generated in the correct order. I definitely want the curve drawn on top of the background.
And, in this case, I didn’t think I would need to concern my self with the data limits. As I would not be mixing the background and curve on the same axis. Only the size and placement of each axes would matter. But one never knows…
I do not yet know for certain that this approach will resolve all my issues. But it does seem to work reasonably well. And, it makes things much easier with the curves built using markers
, as it pretty much takes care of fitting the curve to the available space. Something that with the above approach I had to account for when the marker sizes were large.
I start by creating a figure, then manually add two axes
and finally draw the background in one axes and the curve in the other.
fig = plt.figure(figsize=(fig_sz,fig_sz), frameon=False)
wdv = .715
llv = (1 - wdv) / 2
# make background axes 10% larger
wdv2 = wdv * 1.10
llv2 = (1 - wdv2) / 2
# background axes
# set zorder so it is lower in the drawing order
ax2 = fig.add_axes([llv2, llv2, wdv2, wdv2], zorder=1)
ax2.axis('off')
ax2.autoscale(False)
xn3, xx3 = ax2.
# curve axes
# set zorder so it is higher in the drawing order
ax = fig.add_axes([llv, llv, wdv, wdv], zorder=10)
# need set background patch transparency so that we can see the background colours
ax.patch.set_alpha(0)
ax.axis('off')
# want to make sure the curve fills the axes' drawing area as best as possible
ax.autoscale(True)
For the background, I need to mess a bit with the axes limits. extent=
in imshow()
works with data coordinates. So, I get those limits before calling the background plotting function. (Looks like the defaults are (0, 0, 1, 1), so could perhaps skip this step, but…)
xmin, xmax = ax2.get_xlim()
ymin, ymax = ax2.get_ylim()
bax = gradient_image(ax2, extent=(xmin, xmax, ymin, ymax), direction=g_dir, cmap_range=(v_min, v_max), transform=ax.transAxes, cmap=cmap, alpha=bg_lpha)
I then go ahead and plot the curve. I no longer adjust any of the data limits nor set the aspect ratio.
Examples
These examples show reduced versions of the generated images. So, there is a solid colour background for the full figure (should be white). Which in these samples was originally 14x14 inches. Reduced to 800x800 pixels for the post. Then there is the background axes and the curve plotting axes, each at there own reduced size. The latter originally being around 9.9x9.9 inches. The former approximately 10.9x10.9 inches (originally).
With the way I am initializing the figure
I need to tell savefig
to use the figure’s facecolour. And, make sure it is not transparent.
plt.savefig(f_pth, facecolor=fig.get_facecolor(), transparent=False)
And, that didn’t work when I opened images in my image application. Seems it is something in the app and not in Matplotlib or Python. So manually editing the background colour in the app.
Done
Think that’s it for this one. Hopefully, I will eventually sort the change from a white to a grey background for the figure area outside the two axes.
Enjoy your coding time!
And, yes it was my software. The main window colour was set to that grey colour. Changed it to white and all now good.
Resources
- Transformations Tutorial
- Image Demo
- matplotlib.figure.add_axes
- matplotlib.axes.Axes.axis
- matplotlib.axes.Axes.autoscale
- matplotlib.axes.Axes.get_xlim
- matplotlib.axes.Axes.get_ylim
- matplotlib.axes.Axes.set_aspect
- matplotlib.axes.Axes.set_adjustable
- matplotlib.patches.Patch.set_alpha
- matplotlib.pyplot.imshow
- Turning axes off and setting facecolor at the same time not possible?
- Bar chart with gradients