Before starting on the previous post, I had been, one night, laying awake thinking about the margin issue with cycling line width images using scatterplot I had tried to resolve. A number of things crossed my mind. What should I be using for a margin in the on-screen app? What should I be using when repeating an image suitable for printing and framing? Or for that matter, on-screen when trying to generate an image for printing and framing? And, why was I adding code in multiple places to deal with this issue. Seemed like a time for a margin-specific black box.

And, in that last post, I realized I had a lot of code in the CLW (scatter) generator functions that could be removed to their own functions to make things a lot more tidy and DRY. For example:

  • selecting random marker types
  • selecting maximum marker size
  • determining cycle frequencies; this is also done in the CLW (between) functions/routes
  • determing start location for generating line width cycles; also in CLW (between)

And, that’s just off the top of my head at the moment. So let’s get at it. I’ll start with the margins.

Plot Margins

Test Module

When setting a margin value, we are actually setting a percentage value Matplotlib’s autoscaling feature will use to determine the plot data limits. I don’t know the exact details; but, it does something like add the specified fraction of the available plot area to the plot’s x and y limits. How it does that is more than I wanted to figure out.

But, during that night-time session, I figured a ¼ inch border on-screen would be more than enough. But, for printing and framing I am thinking ⅜ to ½ would be more appropriate. And, for larger image sizes that might need to increase. For now I will default to ⅜ for the print/frame scenario.

I started by writing some test code, margins.test.py, to see if I knew enough to sort this out. I especially wanted to make sure I was dealing correctly with the different DPI settings I use for on-screen (72 dpi) and print versions (300 dpi).

This initial test code got a CLW (scatter) plot to display. It pretty much uses all the same functions used by the equivalent route. You will have to take my word that it works—for now at least.

In the app, I use matplotlib.figure.Figure() to instantiate my figure element. But, Figure does not allow generating an image at the command line as I wish to do in this test module. So, I have reverted to using matplotlib.pyplot.figure().

import base64, math, pathlib, time, os

import numpy as np  
from matplotlib.figure import Figure
import matplotlib.pyplot as plt

import main as main
from spiro_get_rand import get_radii, get_freqs
import spiro_plotlib as splt
import sp_app_lib as sal
import g_vars as g

dflt_dpi = 72
dflt_sz = 10

# instantiate Numpy random generator for use in module
rng = np.random.default_rng()

# create figure and axes, don't want to use pyplot to produce image
fig = plt.figure(figsize=(dflt_sz, dflt_sz), frameon=False, dpi=dflt_dpi)

# image axes, remove ticks and such
ax = fig.add_axes([0.0, 0.0, 1.0, 1.0], zorder=10)
for spine in ax.spines.values():
  spine.set_visible(False)
ax.tick_params(bottom=False, labelbottom=False, left=False, labelleft=False)

f_data = {"shp_mlt": ""}
t_xs, t_ys, *_ = main.get_curve_data('clw_scatter', f_data, 2048)

main.setup_image(ax)

tmp_lcf = g.lc_frq
tmp_hff = g.hf_frq
c_div = rng.choice([4, 6, 8, 10, 12, 14, 16, 18, 20])
g.lc_frq = g.t_pts // c_div
g.hf_frq = g.lc_frq // 2

g.mrk_rnd = True
ax.autoscale()
m1, m2, mx_sz, t_fst, p_step, hf_frq, ax_mrgn = cycle_lw(ax, t_xs[-1], t_ys[-1])

g.lc_frq = tmp_lcf
g.hf_frq = tmp_hff

data = main.fini_image(fig, ax)

plt.show()

Now, because the cycle_lw() function in the sp_app_lib module sets the margins, I had to add a version of the function to this test module and remove all the margin setting code. I also added the line ax.margins(0, 0) to the test module, right after the call to setup_image(). And, I got the following. Which is good, because that’s what I expected to see.

example of cylcing line width (scatter) image with no margin, so scatter markers exceed limits of plot axes
No margins on plot axes

For confirmation I am printing out some info.

  data lower left: (-1.6231935001279163, -1.4130518676239718); upper right: (1.623638941068149, 1.4130518676239732)
  x_mrg: 0; y_mrg: 0
  axes lower left: (-1.6231935001279163, -1.4130518676239718); upper right: (1.623638941068149, 1.4130518676239732)
  x_mrg: 0; y_mrg: 0

Margins Function, set_margins()

Let’s get started on the function to set the margins. I will start by just adding the code to set the default on-screen margin of ¼ inch.

Something to note.

Specifying any margin changes only the autoscaling; for example, if xmargin is not None, then xmargin times the X data interval will be added to each end of that interval before it is used in autoscaling.

def set_margins(axt, u_mrg=.25, m_sz=None):
  img_ext = axt.get_window_extent().width
  f_dpi = axt.figure.dpi
  m_pts = u_mrg * 72
  x_mrg = m_pts / img_ext
  axt.margins(x_mrg, x_mrg)  
  return x_mrg

And in the cycle_lw() function I added the line set_margins(axt) right after the code that sorts the two marker types. I also added code to print horizontal and vertical lines for the minimum and maximum y-values in the data set. Added the code right after the call to cycle_lw(). I am also printing out the relevant values so I can check against the image. I am not plotting the data limits because that would add another margin worth of padding to the image.

x_mn, x_mx, y_mn, y_mx = sal.get_plot_bnds(t_xs, t_ys, x_adj=0)
print(f"\tdata lower left: ({x_mn}, {y_mn}); upper right: ({x_mx}, {y_mx})")
ax.axvline(x_mn)
ax.axvline(x_mx)
ax.axhline(y_mn)
ax.axhline(y_mx)

And a quick test.

example of cylcing line width (scatter) image with 1/4 inch margin, scatter markers still exceed limits of plot axes
¼ inch margins on plot axes

That confirmation data for this one is as follows.

  x_mrg: 0; y_mrg: 0
  data lower left: (-0.9899753563829395, -2.2340717945113147); upper right: (1.5067112621024084, 2.2340717945113147)
  axes lower left: (-1.0523925218450731, -2.3457753842368803); upper right: (1.569128427564542, 2.3457753842368803)
  x_mrg: 0.025; y_mrg: 0.025

Given the image size is set at 10 inches by 10 inches. A ¼ inch margin would indeed be 0.025 or 2.5% of the image width. So, seem to have gotten that bit right.

Okay, let’s test that with a ⅜ inch margin. I have replaced the call to set_margins in cycle_lw() with set_margins(axt, u_nrg=.375). And, I have changed the image size to dflt_sz = 12.

example of cylcing line width (scatter) image with 3/8 inch margin, scatter markers still exceed limits of plot axes
⅜ inch margins on plot axes
  x_mrg: 0; y_mrg: 0
  data lower left: (-1.179312262248347, -1.0180160376041498); upper right: (1.179342003217325, 1.0180160376041525)
  axes lower left: (-1.2530202080441493, -1.0816420399544093); upper right: (1.2530499490131273, 1.081642039954412)
  x_mrg: 0.03125; y_mrg: 0.03125

Not horribly obvious, but it appears to have worked (.375 / 12 = .03125).

Marker Size

Okay, time to deal with marker size. For the most extreme points on the spirograph, I expect that roughly half the marker image will extend beyond the point and half will not. So, I am going to add half the marker size to the specified margin to generate the new margin. I am also going to print additional info to the terminal to give me some idea of what is going on in my code.

I have also refactored the call to cycle_lw to include a parameter for a user specified base margin.

def cycle_lw(axt, rxs, rys, u_m1=None, u_m2=None, u_mx=None, u_fst=None, u_stp=None, u_mrg=0.25)

The set_margins() function now looks as follows.

def set_margins(axt, u_mrg=.25, m_sz=None):
  x_ext = axt.get_window_extent().width
  y_ext = axt.get_window_extent().height
  print(f"\tx_ext: {x_ext}; y_ext: {y_ext}")

  m_pts, mx_pts = u_mrg * 72, 0
  if m_sz:
    mx_pts = (m_sz**0.5) * 0.5

  x_mrg = (m_pts + mx_pts) / x_ext
  y_mrg = x_mrg
  axt.margins(x_mrg, y_mrg)  

  print(f"\tu_mrg: {u_mrg} ({m_pts} pts), m_sz: {m_sz} (1/2 size {mx_pts} pts), x_mrg: {x_mrg}, y_mrg: {y_mrg}")

  return x_mrg

And in cycle_lw() the call to set_margins() is:

x_mrg = set_margins(axt, u_mrg=u_mrg, m_sz=mx_sz)

And in the test module, I now have the following (not complete code, just some relevant stuff).

... ...

dflt_dpi = 72
dflt_sz = 10
use_mrgn = 0.25

... ...

m1, m2, mx_sz, t_fst, p_step, hf_frq, ax_mrgn = cycle_lw(ax, t_xs[-1], t_ys[-1], u_mrg=use_mrgn)

... ...
x_mrg, y_mrg = ax.margins()
print(f"\t\tx_mrg: {x_mrg}; y_mrg: {y_mrg}")
# convert maximum x and y data values to axes coordinates, mark point on spiro image
axes_coord = get_axes_coord(ax, (x_mx, y_mx))
ax.plot(x_mx, y_mx, 'ro')
# print fig coords and ratio of x data range to plot limit range
print(f"\t\taxes_coord upper right curve coords: {axes_coord}; % of data limits {((xl_mx - xl_mn) - (x_mx - x_mn)) / (x_mx - x_mn)}")

... ...

And that new function looks as follows.

def get_axes_coord(axt, point):
  trans = axt.transData.transform(point)
  trans = axt.transAxes.inverted().transform(trans)
  return trans

And a test run produced the following.

example of cylcing line width (scatter) image with base 1/4 inch margin adjusted for maximum scatter marker size
Base ¼ inch margin adjusted for maximum marker size
x_mrg: 0; y_mrg: 0
x_ext: 720.0; y_ext: 720.0
u_mrg: 0.25 (18.0 pts), m_sz: 5500 (1/2 size 37.080992435478315 pts), x_mrg: 0.07650137838260877, y_mrg: 0.07650137838260877
data lower left: (-1.118227277979096, -1.20427926109521); upper right: (1.2339921804660687, 1.2042792610952098)
        axes lower left: (-1.2981753088085446, -1.3885373079579564); upper right: (1.4139402112955173, 1.3885373079579562)
        x_mrg: 0.07650137838260877; y_mrg: 0.07650137838260877
        axes_coord upper right curve coords: [0.93365031 0.93365031]; % of data limits 0.15300275676521746

Plot/Show Base Margin

I decided I’d like to show the base margin on the image. To get some idea of how much was added for the maximum marker size. Added the following to the module code just before printing out all the curve and image details (haven’t shown you any of that).

# need to zero margins or plotting the lines expands the margin
ax.margins(0, 0)
rt = (dflt_sz - use_mrgn) / dflt_sz
# function for conversion of axes coords to data coords
axis_to_data = ax.transAxes + ax.transData.inverted()
ix, iy = axis_to_data.transform((rt, rt))
# bottom and top sides
print(f"rt: {rt}, (ix, iy): ({ix}, {iy})")
ax.axvline(ix, c='k')
ax.axhline(iy, c='k')
# left and bottom sides
lf = use_mrgn / dflt_sz
ix, iy = axis_to_data.transform((lf, lf))
print(f"lf: {lf}, (ix, iy): ({ix}, {iy})")
ax.axvline(ix, c='k')
ax.axhline(iy, c='k')

And an example, with ⅜ inch base border.

example of cylcing line width (scatter) image with base 3/8 inch margin adjusted for maximum scatter marker size, with base margin marked off
Base ⅜ inch margin adjusted for maximum marker size, base margin marked
x_mrg: 0; y_mrg: 0
x_ext: 720.0; y_ext: 720.0
u_mrg: 0.375, m_sz: 4500, x_mrg: 0.08408474953124562, y_mrg: 0.08408474953124562
data lower left: (-0.6346246847706487, -0.9699509040013553); upper right: (1.144200632808636, 0.9699509040013543)
        axes lower left: (-0.7841967660591413, -1.1330670616424736); upper right: (1.2937727140971287, 1.1330670616424727)
        x_mrg: 0.08408474953124562; y_mrg: 0.08408474953124562
        fig_coord upper right curve coords: [0.92802008 0.92802008]; % of data limits 0.16816949906249115
rt: 0.9625, (ix, iy): (1.2158488585912688, 1.0480870320192877)
lf: 0.0375, (ix, iy): (-0.7062729105532812, -1.048087032019288)

In the image above, we can see that the adjustment for the marker size managed to do a reasonable job. This is not always/usually the case. Especially for the problematic marker types.

Done M’thinks

I had planned to get much more covered in this post. But that test module took a bit of work. Especially the data transforms. And the post is rather lengthy already. So, refactoring margins in the app modules will just have to wait.

But what we have appears to work as expected. So should be easy enough to refactor margin handling in the app.

Until we meet again, don’t be afraid of what you don’t know. Let trial, error and/or the web give you a hand.

Resources