As mentioned last time, I would like to get some nicer colour choices for the gnarly style images. And, time permitting, allow the user to re-use the current base curve data, only changing parameters related to displaying the image. So, let’s get started.

Colour Maps

In my development for my personal command line app, I allowed for the use of a number of different colour maps with matplotlib’s plotting of the images. In my opinion, this greatly enhanced the images. Now, it’s not to say I selected the best colour maps or used them in the best possible way, but I liked the result. So, let’s see about replicating some of that functionality in this web app.

Getting a little unhappy with the list of parameters being passed to the form generation page. So going to change most that into a single dictionary containing all the data I need to pass to the page in order to generate the desired form. But, think I will continue to use the type parameter on its own. Though that may change down the road.

Add New Variables and Functions

Want the user to be able to choose a colour map if they so desire. Or, let the app randomly choose one for them. The data for the colour maps I currently make available is in the main module for my personal spirograph app. So will need to copy the colour map variables and related functions to sp_app_lib.py and refactor. Will also need some new variables in g_vars.py.

Here’s the new variables in g_vars.py. A list of all the colours, and some variables to hold user choices and to specify the size of the colour cycle (i.e. how many colours from the colour map to use in the colour cycle for matplotlib).

clr_opt = ['autumn', 'bone', 'BuGn', 'BuPu', 'cividis', 'cubehelix',
  'cool', 'copper', 'default', 'GnBu', 'gist_earth', 'gnuplot', 
  'hot', 'inferno', 'jet', 'magma', 'plasma', 'PuBu', 'PuBuGn',
  'PuRd', 'rainbow', 'RdPu', 'summer', 'tab20', 'tab20c', 'terrain',
  'turbo', 'twilight_shifted', 'viridis', 'winter', 'YlGnBu', 'YlOrRd']

rcm = 'bone'    # colour to use if user specified one, otherwise use current colour again ???
cycle = []    # list of colours for current colour map (rcm)
lc_frq = max(20, t_pts // 20)   # number of colours
hf_frq = lc_frq // 2

And, the changes to the library module. A dictionary linking colour map names to the actual matplotlib colour map objects. And, the function to actually set the colour map for the passed in plot axes.

bgcm_mpl = {
  'autumn': plt.cm.autumn, 'bone': plt.cm.bone, 'BuGn': plt.cm.BuGn_r, 'BuPu': plt.cm.BuPu_r,
  'cividis': plt.cm.cividis,
  'cubehelix': plt.cm.cubehelix, 'cool': plt.cm.cool ,'copper': plt.cm.copper,
  'default': plt.cm.tab10, 'GnBu': plt.cm.GnBu_r,
  'gist_earth': plt.cm.gist_earth, 'gnuplot': plt.cm.gnuplot, 'gray': plt.cm.gray,
  'Greys': plt.cm.Greys, 'hot': plt.cm.hot, 'hsv': plt.cm.hsv, 'inferno': plt.cm.inferno,
  'jet': plt.cm.jet, 'magma': plt.cm.magma, 'ocean': plt.cm.ocean ,'plasma': plt.cm.plasma,
  'PuBu': plt.cm.PuBu_r, 'PuBuGn': plt.cm.PuBuGn_r, 'PuRd': plt.cm.PuRd_r, 'rainbow': plt.cm.rainbow,
  'RdPu': plt.cm.RdPu_r, 'reds': plt.cm.Reds,
  'summer': plt.cm.summer, 'tab20': plt.cm.tab20, 'tab20c': plt.cm.tab20c, 'terrain': plt.cm.terrain,
  'turbo': plt.cm.turbo, 'twilight_shifted': plt.cm.twilight_shifted, 'viridis': plt.cm.viridis,
  'winter': plt.cm.winter, 'Wistia': plt.cm.Wistia, 'YlGnBu': plt.cm.YlGnBu_r, 'YlOrBr': plt.cm.YlOrBr_r,
  'YlOrRd': plt.cm.YlOrRd_r
}

... ...

def set_clr_map(ax, n_vals=None, cc=None):
  # version 0.2.0
  c_cycle = []
  u_cmap = None

  if cc and cc in g.clr_opt:
    u_cmap = cc
  else:
    u_cmap = np.random.choice(g.clr_opt)

  if not n_vals:
    n_vals = 16
  c_rng = n_vals // 2

  if u_cmap in ['default', 'tab20', 'tab20c', 'twilight_shifted']:
    c_cycle = [bgcm_mpl[u_cmap](i) for i in np.linspace(0, 1, n_vals+1)][:-1]
  else:
    if c_rng % 2 == 0:
      c_cycle = [bgcm_mpl[u_cmap](i) for i in np.linspace(0, 1, c_rng+1)] + [bgcm_mpl[u_cmap](i) for i in np.linspace(0, 1, c_rng+4)][2:-2][::-1]
    else:
      c_cycle = [bgcm_mpl[u_cmap](i) for i in np.linspace(0, 1, c_rng+1)] + [bgcm_mpl[u_cmap](i) for i in np.linspace(0, 1, c_rng+4)][2:-2][::-1]
  c_cycle = c_cycle[:n_vals]

  ax.set_prop_cycle('color', c_cycle)
  # print(f"set_clr_map() -> {u_cmap}, {clr_opt[u_cmap]}, {n_vals}, {len(c_cycle)}")

  return u_cmap, c_cycle

Refactor Page Templates

Will also refactor the page templates to use a dictionary of form related variables for the various fields, as mentioned above. I refactored the two templates before doing so to the routes in main.py.

Most of it is pretty straightforward, but I will include the code for both. First get_curve.html. The various values needed by the template are now mostly in the dictionary f_data. As mentioned, I did leave type as a standalone parameter.

{% extends 'base.html' %}

{% block title %}{{ f_data['pttl'] }}{% endblock %}

{% block content %}
  <h2>Generate a {{ f_data['pttl'] }}</h2>

  <p>The form below will let you specify some of the parameters for producing a spirograph curve/image or one of the variations.</p>
  <p>You can use a number of different wheel shapes:</p>
  <ul>
  {% for shape in f_data['shapes'] %}        
    <li>`{{ shape }}` => {{ f_data['shapes'][shape] }}</li>
  {% endfor %}
  </ul>
  <p style="margin-bottom: 2rem;">You are able to specify the number of wheels, a single shape for each wheel or a different shape for each wheel, and the width of the line used to generate the image. If you select a single shape for all wheels, anything you enter in the multiple shape field will be ignored.</p>
  {% if type == 'gnarly' %}
  <p>For <i>gnarly</i> type images, it is often best not to include one or more data rows at the top or bottom of the curve's complete dataset. To that end you can specify how many to drop and whether to drop from the top or bottom of the full dataset. I do, however, not recommend dropping more than a quarter of the rows or so. Or leaving at least 2 rows, so there is something to use to create the image.</p>
  {% endif %}
  
  <form action="" method="post">
    <div >
      <label for="wheels">Number of wheels: </label><br>
      <input type="text" name="wheels" placeholder="'r' for random, or number 3-13 inclusive"/>
    </div>

    <div class="orsect">
      <div>
        <label for="shape">Single Wheel Shape: </label><br>
        <select name="shape">
          <option value="" selected="true" disabled>Select Shape</option>
        {% for shape in f_data['shapes'] %}        
          <option value="{{ shape }}">{{ f_data['shapes'][shape] }}</option>
        {% endfor %}
          <option value="x">Random Choice</option>
        </select>
      </div>
      <p>&#8212;&#8212;&#8212;&#8212;&#8212;&#8212; <b>OR</b> &#8212;&#8212;&#8212;&#8212;&#8212;&#8212;</p>
      <div>
        <label for="shp_mlt">Multiple Wheel Shapes: </label><br>
        <input type="text" name="shp_mlt" placeholder="comma separated list of shapes (e.g. c, r, r, t, e), or 'rnd' for random"/>
        <p style="display:none;margin-top:.25rem;margin-left:2rem;">Allowed shapes: {{ ", ".join(f_data['shapes'].keys()) }}</p>
      </div>
    </div>

    {% if type == 'gnarly' %}
    <div>
      <div>
        <label for="torb">Drop from Top or Bottom</label><br>
        <select name="torb" style="width: 28rem;">
          <option value="" selected="true" disabled>Select an end or leave unchanged to not drop any rows</option>
          <option value="top">Drop from top</option>
          <option value="btm">Drop from bottom</option>
        </select>
      </div>
      <div >
        <label for="drows">Number of rows to drop: </label><br>
        <input type="text" name="drows" placeholder="'r' for random, or a number less than half the number of wheels"/>
      </div>
    </div>
    {% endif %}

    <div>
      <label for="ln_sz">Line size ('r' for random, or number 1-10 inclusive): </label><br>
      <input type="text" name="ln_sz"/>
    </div>

    <div>
      <label for="cmap">Colour Map: </label><br>
      <select name="cmap">
        <option value="" selected="true" disabled>Select Colour Map</option>
        <option value="r">Random Choice</option>
      {% for cmap in f_data['cmaps'] %}        
        <option value="{{ cmap }}">{{ cmap }}</option>
      {% endfor %}
      </select>
    </div>

    <input type="submit" value="Display Image" />
  </form>
  {% endblock %}

Similarly for disp_image.html. But in this case I also pass the image data in a standalone parameter.

{% extends 'base.html' %}

{% block title %}{{ p_data['pttl'] }}{% endblock %}

{% block content %}
  <h2>{{ p_data['pttl'] }}</h2>

    <p style="display:none;">Hopefully we will get to see an image soon.</p>
    <p>You requested {{ p_data['n_whl'] }} wheels (shape(s): {{ p_data['shape'] }}).
       The image will use a line width of {{ p_data['ln_sz'] }}.
       The colour map being used is {{ p_data['cmap'] }}.
      </p>
    {% if type == 'gnarly' %}
    <p>You requested that {{ p_data['d_nbr'] }} {{ 'datarows' if p_data['d_nbr'] > 1 else 'datarow' }}
       be dropped from the '{{ p_data['d_end'] }}' of the curve dataset. </p>
    {% endif %}

    <img src='data:image/png;base64,{{ sp_img }}'/>

    <p>
    {% if type == 'basic' %}
      <a href="{{ url_for('sp_basic') }}">Back</a></p>
    {% elif type == 'gnarly' %}
      <a href="{{ url_for('sp_gnarly') }}">Back</a></p>
    {% endif %}
{% endblock %}

The form template is certainly longer than the image display template. And, that is only likely to get worse.

Refactor Routes

Again pretty straightforward. Create dictionaries and populate as necessary. Also set the colour cycle for the matplotlib axes object. Most of the previous code is unchanged, but I am including it anyway. I believe it makes it easier to see what is happening.

@app.route('/spirograph/basic', methods=['GET', 'POST'])
def sp_basic():
  pg_ttl = "Basic Spirograph Image"
  if request.method == 'GET':
    f_data = {'pttl': pg_ttl, 'shapes': splt.shp_nm, 'cmaps': g.clr_opt}
    return render_template('get_curve.html', type='basic', f_data=f_data)
  elif request.method == 'POST':
    # app.logger.info(f"POST[shape]: {request.form.get('shape')}, POST[shp_mlt]: {request.form.get('shp_mlt')}")
    # print(f"POST[shape]: {request.form.get('shape')}, POST[shp_mlt]: {request.form.get('shp_mlt')}")

    sal.proc_curve_form(request.form, 'basic')
    t_xs, t_ys, t_shp = sal.init_curve()

    ax.clear()
    g.rcm, g.cycle = sal.set_clr_map(ax, g.lc_frq, g.rcm)

    ax.plot(t_xs[-1], t_ys[-1], lw=g.ln_w, alpha=1)
  
    # Save it to a temporary buffer.
    buf = BytesIO()
    fig.savefig(buf, format="png", bbox_inches="tight")
    # Embed the result in the html output.
    data = base64.b64encode(buf.getbuffer()).decode("ascii")

    p_data = {'pttl': pg_ttl, 'n_whl': g.n_whl, 'ln_sz': g.ln_w, 'shape': t_shp,
              'cmap': g.rcm
             }

    return render_template('disp_image.html', sp_img=data, type='basic', p_data=p_data)


@app.route('/spirograph/gnarly', methods=['GET', 'POST'])
def sp_gnarly():
  pg_ttl = "Gnarly Spirograph Image"
  if request.method == 'GET':
    f_data = {'pttl': pg_ttl, 'shapes': splt.shp_nm, 'cmaps': g.clr_opt}
    return render_template('get_curve.html', type='gnarly', f_data=f_data)
  elif request.method == 'POST':
    # print(f"POST[shape]: {request.form.get('shape')}, POST[shp_mlt]: {request.form.get('shp_mlt')}")

    sal.proc_curve_form(request.form, 'gnarly')
    t_xs, t_ys, t_shp = sal.init_curve()

    r_xs, r_ys, m_xs, m_ys, m2_xs, m2_ys = sal.get_gnarly_dset(t_xs, t_ys, g.r_skp, g.drp_f, g.t_sy)

    ax.clear()
    g.rcm, g.cycle = sal.set_clr_map(ax, g.lc_frq, g.rcm)

    ax.plot(r_xs, r_ys, lw=g.ln_w, alpha=g.alph, zorder=g.pz_ord)
    ax.plot(m_xs, m_ys, lw=g.ln_w, alpha=g.alph, zorder=g.pz_ord)
    ax.plot(m2_xs, m2_ys, lw=g.ln_w, alpha=g.alph, zorder=g.pz_ord)
  
    # Save it to a temporary buffer.
    buf = BytesIO()
    fig.savefig(buf, format="png", bbox_inches="tight")
    # Embed the result in the html output.
    data = base64.b64encode(buf.getbuffer()).decode("ascii")

    d_end = "top" if g.drp_f else "bottom"
    p_data = {'pttl': pg_ttl, 'n_whl': g.n_whl, 'ln_sz': g.ln_w, 'shape': t_shp,
              'd_end': d_end, 'd_nbr': g.r_skp, 'cmap': g.rcm
             }

    return render_template('disp_image.html', sp_img=data, type='gnarly', p_data=p_data)

Example

I am not going to add an images of the form web page. I will just show a couple displaying a gnarly print with something other than a tab20, the default, colour map. Just so I can say my code appears to work.

screen shot of gnarly spirograph image using a user selected colour map screen shot of gnarly spirograph image using a user selected colour map

Done

Once again, I have deployed to the cloud, but am not including any of the command line interaction to get that done.

I was going to look at reusing curve data. But, I am trying to sort how to go about that. I had thought of adding a radio button (reuse or new). But I am now thinking that if the user goes to the form page and doesn’t change any of the curve specific fields then I would just reuse whatever is in the global variable for the unchanged fields. For curve specific fields, that would currently be the number of wheels and their shapes for all curves. And the drop value for gnarly curves. Line width and colour map could be readily changed. But, if not I plan to reuse them as well. So, if no form fields get new values, the exact same image should be displayed again.

But, that is likely a bit of thinking and refactoring. So I will leave it for the next post.

Until then, I hope you find plenty of time for coding fun.

Resources