Okay, let’s have a look at the possibilities of that plotting “error” I made some time back.
The image in the post referenced above was the final frame of an animation. I don’t really want to generate animations just to get one frame. Too much time involved in generating those animations. So, I am going to look at just plotting a range of the extra lines returned by the curve parametric function we refactored the other day. I will start a new module for the code for this post, spiro_mln.test.py
. I.E. multi-line.
I may also be refactoring those packages I created for the last post.
First Attempt
Module Basics
Let’s set up some basics. A module description, the imports and some variables of potential use.
""" r:\learn\py_play\spirograph\spiro_mln.py:
Use matplotlib to generate a plot of continuous curves using the parametric technique covered in :
FARRIS, Frank A. "Wheels on Wheels on Wheels-Surprising Symmetry.
Mathematics Magazine 69, No. 3 (1996): 185-189.
This module will allow for the drawing of curves based on 3 to 8 wheels.
Radii and rotational frequencies will be specified in 'globally accessible' variables.
But unlike previous code, one or more of the 'lines' retuned by f() will be
plotted rather than just the final curve. An attempt at something more artistic.
Version 0.1.0, 2022.02.10
"""
import argparse, pathlib
import matplotlib.pyplot as plt
import numpy as np
from spiro_get_rand import get_radii, get_freqs
import spiro_plotlib as splt
# Misc vars
# Defaults
# how many of the f(t) lines to plot
r_keep = None
# number of points (t for f(t)) to generate
t_pts = 100
Number of Lines to Keep
I was going to add a command line parameter for this value. But it depends on the number of wheels used to generate the curve, so I decided to add a function to generate a somewhat random value. Nothing fancy, but should make my life easier.
# functions
def get_ln_keep(nw):
# nw: number of wheels, use to generate random number of lines to keep on plot
r_keep = []
if nw == 3:
r_keep = [2]
elif nw <= 5:
# lower wheel numbers keep from 2 to all radial lines
r_keep = [np.random.choice(list(range(2, nw-1)))]
else:
# higher wheel numbers keep from 3 to all radial lines
r_keep = [np.random.choice(list(range(3, nw-2)))]
return r_keep
Note: as I review this draft before publishing, I really don’t recall why I put that number in a list!?
Generate Random Curve
Covered this in the previous post. I will also get the number of lines to keep in our plot.
# generate a random curve
# number of circles initialized one time, rather than in multiple functions
nbr_c = np.random.randint(3, 9)
k_f = np.random.randint(2, nbr_c+1)
c_val = np.random.randint(1, k_f)
sp_rds = get_radii(nbr_c)
print(f"get_radii({nbr_c}) -> {sp_rds}")
cgv, sp_frq = get_freqs(nbr_w=nbr_c, kf=k_f, mcg=c_val)
print(f"get_freqs(nbr_w={nbr_c}, kf={k_f}, mcg={c_val}) -> {sp_frq}")
# convert all radii to real, again one time globablly accessible
r_rad = [max(np.real(rd), np.imag(rd)) for rd in sp_rds]
# how many lines to keep/plot
# plan to do this often and want to vary based on number of wheels
# so wrote function
r_keep = get_ln_keep(nbr_c)
print(f"r_keep: {r_keep}")
Set Up the Basic Figure
Again nothing new here
# create figure
fig, ax = plt.subplots(figsize=(8,8))
plt.axis('off')
# leave room for title
ax.set_position([0, 0, .9, 1], which='both')
# set the aspect ratio to square
ax.set_aspect('equal')
# an array of the t values to use to plot the curve
# 500 should keep the curve smooth enough at this plot size
# and this will also determine the number of frames in the animation
t = np.linspace(0, 2*np.pi, t_pts)
# set up the plotting package
splt.set_spiro(sp_frq, sp_rds, t_pts)
Generate the Plot
This took a little experimentation, but I finally settled on something that worked for me. Well after some enhancement we will get into as the post progresses. You might want to try other things to see what happens and whether you get something better overall.
Note, starting loop at line number 2
. We really don’t want to plot the final curve.
# now let's give it a try
r_pts = splt.f(t)
for ln in range(2, r_keep[0]+1):
plt.plot(np.real(r_pts[-ln:]), np.imag(r_pts[-ln:]))
plt.show()
Give It a Go
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py
get_radii(7) -> [1, 0.7384578971550536, 0.6409857326410648, 0.3251521811464025j, 0.23446122516595472, 0.19944844679141316j, 0.1284437317461046j]
get_freqs(nbr_w=7, kf=6, mcg=4) -> [4, 28, -14, -14, -2, 4, 22]
r_keep: [4]
Enhancements
Random Line Widths
The first thing I decided to do was add random line widths for each line. Again, I felt this called for a function. But this one I decided to add to the spiro_plotlib
package. That said, here it is. This is the final version, I went through a fair number of refactorings. I also, in the end, decided that I might want to use this for other plots. In that case, I might just want to use the default width in some cases, so I added a parameter (is_fancy=
) to allow for that choice.
def get_ln_wd(is_fancy=False, max_wd=23):
if is_fancy:
ln_w = min(max_wd, (13*np.random.randint(1, 4)) % 23)
return ln_w
# None is the default for matplotlib's plot function
return None
Okay, let’s add that to our code. I may add a command line argument to control the behaviour, so let’s also add a new default variable.
# use randon line widths if True, default otherwise
lw_fancy = True
Let’s add getting a line width to the plotting code. And, give it a go. For testing I added printing out the line width. Of course, this will be a random curve so won’t look like the first one.
# now let's give it a try
r_pts = splt.f(t)
for ln in range(2, r_keep[0]+1):
ln_w = splt.get_ln_wd(lw_fancy)
print(f"ln: {ln}, width: {ln_w}")
plt.plot(np.real(r_pts[-ln:]), np.imag(r_pts[-ln:]), lw=ln_w)
plt.show()
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py
get_radii(7) -> [1, 0.512160243095295j, 0.45062679518786897j, 0.42936433656507733, 0.2699843790305321, 0.14667633598326682, 0.13725879567309957]
get_freqs(nbr_w=7, kf=7, mcg=5) -> [5, 19, 19, 19, -16, -16, 12]
r_keep: [3]
ln: 2, width: 13
ln: 3, width: 3
Add Transparency
Next, I thought I’d look at adding a random transparency value to each line. So a new function in the spirograph plotting package. An extra bit of code to generate the plots. Since I will control this on a plot by plot basis, I did not add a variable for a default value in this module.
In spiro_plotlib
I added the following function.
def get_alpha(is_fancy=True):
# return a random alpha value between .2 and .9
if is_fancy:
min_a, max_a = 0.2, 0.9
return (max_a - min_a) * np.random.random_sample() + min_a
return None
Our refactored plotting code. Again printing extra information for testing purposes.
# now let's give it a try
r_pts = splt.f(t)
print(f"len r_pts[-2:] --> {r_pts[-2:][:10]}")
for ln in range(2, r_keep[0]+1):
ln_w = splt.get_ln_wd(lw_fancy)
ln_alpha = splt.get_alpha()
print(f"ln: {ln}, width: {ln_w}, alpha: {ln_alpha}")
plt.plot(np.real(r_pts[-ln:]), np.imag(r_pts[-ln:]), lw=ln_w, alpha=ln_alpha)
plt.show()
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py
get_radii(7) -> [1, 0.6207763119477836j, 0.371842842745497j, 0.19443675907899535, 0.125, 0.125, 0.125]
get_freqs(nbr_w=7, kf=7, mcg=3) -> [-4, 3, 17, -18, -4, -11, -4]
r_keep: [3]
ln: 2, width: 16, alpha: 0.5234753796542497
ln: 3, width: 3, alpha: 0.8536331376890738
That does seem to add a bit more interest.
But still not what I was hoping for.
Changing the Number of Points
Since I want to try a number of different values, I am going to add a command line parameter for this one. You may have noted that I had already included the appropriate import at the top of the module.
Somewhere near the top of the file, but after the default variable declarations, add the following.
parser = argparse.ArgumentParser(description='Generate spirograph')
parser.add_argument('-p', '--points', action='store', dest='pi_pts',
type=int, help="Specify number of points in plot between 0 and 2pi, Examples: -p 500, default 100")
args = parser.parse_args()
if args.pi_pts:
t_pts = args.pi_pts
Now let’s run a few variations.
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py -p 200
get_radii(8) -> [1, 0.7075718214043143, 0.37036292661994863j, 0.29366510325307305, 0.1599804186121314j, 0.1334220139705753j, 0.125j, 0.125]
get_freqs(nbr_w=8, kf=2, mcg=1) -> [3, -1, -1, -3, -7, 5, 9, 3]
r_keep: [3]
ln: 2, width: 16, alpha: 0.817879309231679
ln: 3, width: 16, alpha: 0.3878679098857327
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py -p 300
get_radii(4) -> [1, 0.5052149552378209j, 0.41756740230620826, 0.24796265093848163]
get_freqs(nbr_w=4, kf=3, mcg=1) -> [-2, -5, 1, 7]
r_keep: [2]
ln: 2, width: 16, alpha: 0.3835277739651046
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py -p 400
get_radii(6) -> [1, 0.5634774234971028j, 0.4687476312184444, 0.37007625464568283, 0.3157314549960819j, 0.26907634873778147]
get_freqs(nbr_w=6, kf=5, mcg=1) -> [6, -14, 6, -14, 21, -9]
r_keep: [3]
ln: 2, width: 13, alpha: 0.3041912266002757
ln: 3, width: 3, alpha: 0.7507449342902335
Looks to me like 100 points is not enough and more generally looks better with higher values. Though I expect the best value depends on the parameters of the curve being used. But, I am going to set the default value of t_pts
to 500. And, run a number of repeated tests to see how things look. Having run those tests, I will leave it at 500 for now.
Different Colour Maps
So far I have been going with the default color map. But, I know there are options. So let’s try a few. Another function and some new knowledge regarding matplotlib.
The property cycle controls the style properties such as color, marker and linestyle of future plot commands.
Most important for us, matplotlib cycles through the colours in the axis prop_cycle
for each line plotted. So, I am going to write a function to randomly assign one of a few colour maps I selected from those on the choosing colormaps page in the matplotlib documentation.
For now this function is defined in this module. But I will likely add it to the spirograph plotting package. I expect it will find use in future spirograph efforts. In addition to the colour cycle values, I am returning the colour map name so that I can print/track the colour map being used.
def set_colour_map(ax, n_vals=None):
clr_opt = ['default', 'plt.cm.GnBu_r', 'plt.cm.PuBu_r', 'plt.cm.viridis', 'plt.cm.hot', 'plt.cm.twilight_shifted']
# if random int == 0, leave default color cycle
u_cmap = np.random.randint(0,5)
c_cycle = []
c_rng = 8
if n_vals:
c_rng = n_vals
if u_cmap == 1:
ax.set_prop_cycle('color',[plt.cm.GnBu_r(i) for i in np.linspace(0, 1, c_rng)]) # like this one
elif u_cmap == 2:
ax.set_prop_cycle('color',[plt.cm.PuBu_r(i) for i in np.linspace(0, 1, c_rng)]) # like this one
elif u_cmap == 3:
ax.set_prop_cycle('color',[plt.cm.viridis(i) for i in np.linspace(0, 1, c_rng)]) # like this one
elif u_cmap == 4:
ax.set_prop_cycle('color',[plt.cm.hot(i) for i in np.linspace(0, 1, c_rng)]) # like this one
c_cycle = plt.rcParams['axes.prop_cycle'].by_key()['color']
return clr_opt[u_cmap], c_cycle
And, in the section where we define the plot, let’s
# set up colour cycler for circle colours
clr_opt = ['default', 'plt.cm.GnBu_r', 'plt.cm.PuBu_r', 'plt.cm.viridis', 'plt.cm.hot', 'plt.cm.twilight_shifted']
rcm, cycle = set_colour_map(ax)
print(f"colour map: {rcm}")
Let’s give that a few test runs. I will likely only include one or two of them in this post.
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py
get_radii(3) -> [1, 0.5250133430376055, 0.3157645803705394j]
get_freqs(nbr_w=3, kf=3, mcg=2) -> [2, -1, 5]
r_keep: [2]
colour map: plt.cm.GnBu_r
ln: 2, width: 13, alpha: 0.8678237685480206
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py
get_radii(8) -> [1, 0.6509768897649546j, 0.47337052694828874, 0.3215469628508561j, 0.32100518948946305j, 0.19950037537691814, 0.125, 0.125j]
get_freqs(nbr_w=8, kf=8, mcg=5) -> [13, -27, -19, 21, -19, -27, 29, -3]
r_keep: [5]
colour map: plt.cm.PuBu_r
ln: 2, width: 13, alpha: 0.8629568280659374
ln: 3, width: 13, alpha: 0.671619422370225
ln: 4, width: 13, alpha: 0.7116661153719805
ln: 5, width: 3, alpha: 0.8752312611331701
One last one.
(ds-3.9) PS R:\learn\py_play\spirograph> python spiro_mln.test.py
get_radii(8) -> [1, 0.6906133506316641, 0.4178441336172453, 0.2370265642758671, 0.2202021023532734j, 0.13940350467488896j, 0.12596935712880847, 0.125]
get_freqs(nbr_w=8, kf=7, mcg=6) -> [13, 27, 34, -15, 6, 34, 27, 20]
r_keep: [4]
colour map: plt.cm.hot
ln: 2, width: 13, alpha: 0.8147736723699797
ln: 3, width: 3, alpha: 0.20973050046741595
ln: 4, width: 13, alpha: 0.24341190718041333
Done
I have more things I’d like to try, but I think we have covered enough material in this one. Next time I will look at generating a figure showing these kinds of plot with fixed frequencies or radii and the non-fixed parameter changing for each of 8 subplots. Similar to previous work we’ve done.
I may also look at what happens with changing the line style for each plotted line. I will likely, at that time, also add a post with the updated code for spiro_plotlib
.
Until then, have fun coding.
Resources
- matplotlib.axes.Axes.set_prop_cycle
- Specifying Colors
- Choosing Colormaps in Matplotlib