The development of this web app will likely not follow the sequence in which I generated variations in my original developmental project. Nor, I expect, will I include all the variations I coded. Though I think I would actually like to do so. Time will tell.

So, I think I will now try to get the basic gnarly variation being generated by the app. After a brief aside.

Number of Plotting Points

Last post I had suggested I would add, to the basic spirograph form, a field to allow the user to specify the number of data points to use to generate the image. I have since decided to hold off on that idea. But, I am going to up the default to 1024 from 512. That should in general produce smoother looking images.

A few quick tests seems to indicate the change does produce smoother looking curves.

Gnarly Curves

What I have chosen to call the gnarly variation came about by accident. Well, I suppose, by bug. I had made an error in the x and y data I had provided to a matplotlib plotting function/method. The result was, to me, rather interesting. Things just went on from there.

I have decided to try and use the same form template for all my image variations. Adding/removing fields as needed. Might be more hassle than it’s worth, but then DRY is always a goal in any coding effort. So, I am going to pass another parameter to the template, currently: type='<basic | gnarly>'.

New Route

I will be using a new route, /spirograpn/gnarly. So, I have added another image variation choice, with link, on the home page.

  <h3>Spirograph Images</h3>
    <div class="flex-container">
      <div><a href="{{ url_for('sp_basic') }}">Basic Spirograph</a></div>
      <div><a href="{{ url_for('sp_gnarly') }}"><i>Gnarly</i> Images</a></div>
    </div>

For the moment, so I can test my form, the new route looks like this. Because I will be using the form for multiple image types, I have decided to rename it as get_curve.html.

@app.route('/spirograph/gnarly', methods=['GET', 'POST'])
def sp_gnarly():
  if request.method == 'GET':
    return render_template('get_curve.html', shapes=splt.shp_nm, type='gnarly')
  elif request.method == 'POST':
    ...

Once I get the form working, I will write a new function to handle processing of the form’s response. Don’t want to be repeatedly coding that in each route.

Refactor Curve Parameter Form

Took me a while to sort some of this out. In particular creating the title string, pg_ttl. Tried using a f-string without success. Apparently jinja is not into f-strings. However the old format() approach appears to work just fine. Here’s the additions/changes plus a little extra, the previous form fields are unchanged. I also, in the form tag, set action="". This way the same route will automatically be called with the POST method. Saves a bit of coding trying to specify the desired URL path.

{% extends 'base.html' %}

{% if type == 'basic' %}
  {% set pg_ttl = "{} Spirograph".format(type.capitalize()) %}
{% elif type == 'gnarly' %}
  {% set pg_ttl = "{} Image".format(type.capitalize()) %}
{% endif %}
{% block title %}{{ pg_ttl }}{% endblock %}

{% block content %}
  <h2>Generate a {{ pg_ttl }}</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 shapes %}        
    <li>`{{ shape }}` => {{ 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 use and whether to drop from the top or bottom of the full dataset. </p>
  {% endif %}
  
  <form action="" method="post">

... ...

    {% 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 end or leave unchanged to not drop any rows</option>
          <option value="top">Drop from top</option>
          <option value="btm">Drop from bottomtop</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 %}

... ...

I am going to try and reuse the disp_basic.html template as well. Eventually renaming it to disp_image.html. Which means add the page title code from above to that template as well. And, extending it in both templates for future image type additions. Seems like an undesireable amount of duplication. So, I have since coding the above, decided to generate the title in the route code and pass it to the template.

So the upper bit of the file now looks like this. A lot tidier m’thinks.

{% extends 'base.html' %}

{% block title %}{{ pttl }}{% endblock %}

{% block content %}
  <h2>Generate a {{ 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>
  
... ...

I will get to the code for the routes a little later on.

New Functions

For the most part, a series of global variables are used to generate images. So, I think a function to process the curve form response can simply take the form’s POST data and the current image type, process the data and update the appropriate global variables. I am passing the image type as I think that might simplify sorting which field values I should expect in the passed form data.

When I was looking at the docs for numpy.random.randint I saw the following:

New code should use the integers method of a default_rng() instance instead; please see the Quick Start.

So, that’s what I am going to do for this function and the next. I will look at modifying any other calls to numpy.random at a later time. I also intialize the rng variable as a module variable rather than in each block of code that needs it. So added the following to sp_app_lib.py.

rng = np.random.default_rng()

... ...

def proc_curve_form(f_data, i_typ):
    u_whl = f_data['wheels']
    u_lw = f_data['ln_sz']
    if 'shape' in f_data:
      u_shp = f_data['shape'].lower()
    else:
      u_shp = NotImplemented
    if 'shp_mlt' in f_data:
      u_shps = f_data['shp_mlt'].lower()
    else:
      u_shps = None

    if u_whl.isnumeric():
      g.n_whl = int(u_whl)
    elif u_whl.lower() == 'r':
      g.n_whl = np.random.randint(3, 13)
    else:
      g.n_whl = 3

    if u_shp:
      # had to use something other than 'r' which is used for the rhombus shaped wheel
      if u_shp == 'x':
        g.su = rng.choice(list(splt.shp_nm.keys()))
      elif u_shp in splt.shp_nm:
        g.su = u_shp
    elif u_shps:
      if u_shps == 'rnd':
        # all_shp = list(splt.shps.keys())
        # g.su = [np.random.choice(all_shp) for _ in c_whl]
        g.su = make_mult_shapes("")
      else:
        # !!! need to check for bad shapes, not enough shapes, etc.
        g.su = make_mult_shapes(u_shps)
    else:
      g.su = 'c'

    if u_lw.isnumeric():
      g.ln_w = int(u_lw)
    elif u_lw.lower() == 'r':
      g.ln_w = np.random.randint(1, 10)
    else:
      g.lw_w = 2

    if i_typ == 'gnarly':
      # torb -> "", "top", "btm"; drows -> "r", "0-9"
      # r_skp = 0     # how many rows to drop for gnarly plots
      # drp_f = True  # which end, from front default
      if 'torb' in f_data and f_data['torb'] in ['top', 'btm']:
        g.drp_f = f_data['torb'] == 'top'
        if 'drows' in f_data:
          if f_data['drows'].isnumeric():
            t_rw = int(f_data['drows'])
            if g.n_whl - t_rw >= 2:
              g.r_skp = t_rw
            else:
              g.r_skp = g.n_whl // 4
          elif f_data['drows'] == 'r':
            g.r_skp = rng.integers(1,  g.n_whl // 4 + 1)
          else:
            g.r_skp = 1
      else:
        g.r_skp = 0

Lengthy function, may need to break up in future.

When I went to refactor the code for the two routes, I realized there would be more duplication. So, another function in sp_app_lib.py. This one will likely require some refactoring down the road.

def init_curve():
  # intiialize and generate curve data
  # 'k' fold symmetry
  g.k_f = rng.integers(2, g.n_whl+1)
  # congruency vs k_f
  g.cgv = rng.integers(1, g.k_f)
  # widths of none circular shapes or diameter of circle
  g.wds = get_radii(g.n_whl)
  # heights of none circular shapes
  g.hts = get_radii(g.n_whl)
  # get rid of the imaginary unit if present
  g.r_wds = [max(np.real(rd), np.imag(rd)) for rd in g.wds]
  g.r_hts = [max(np.real(rd), np.imag(rd)) for rd in g.hts]
  # get the actually frequencies and congruency
  g.cgv, g.freqs = get_freqs(nbr_w=g.n_whl, kf=g.k_f, mcg=g.cgv)

  splt.set_spiro(g.freqs, g.wds, g.hts, nbr_t=g.t_pts)
  if len(g.su) == 1:
    t_xs, t_ys = splt.mk_curve(g.rds, shp=g.su)
    shape = f"{splt.shp_nm[g.su].lower()} ({g.su})"
  else:
    shape = g.su
    t_xs, t_ys, shape = splt.mk_rnd_curve(g.rds, su=g.su)

  return t_xs, t_ys, shape

New Routes

There are still two areas of duplication in the two routes. The first, initializing the figure and axes objects can be easily fixed by moving it out of the route function scope into the module scope. Only need it once at that level. But, since matplotlib remembers its state, the plot axes will need to be cleared each time before a new image can be plotted.

At some point I will likely move the second, converting the image to base64 for display by Flask, into a function as well. For now that is duplicated in the two routes of interest.

That said, the two new functions moved a fair bit of code out of the two route functions.

@app.route('/spirograph/basic', methods=['GET', 'POST'])
def sp_basic():
  pg_ttl = "Basic Spirograph Image"
  if request.method == 'GET':
    return render_template('get_curve.html', shapes=splt.shp_nm, type='basic', pttl=pg_ttl)
  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()
    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")

    return render_template('disp_image.html', n_whl=g.n_whl, ln_sz=g.ln_w, shape=t_shp,
       sp_img=data, type='basic', pttl=pg_ttl)


@app.route('/spirograph/gnarly', methods=['GET', 'POST'])
def sp_gnarly():
  pg_ttl = "Gnarly Spirograph Image"
  if request.method == 'GET':
    return render_template('get_curve.html', shapes=splt.shp_nm, type='gnarly', pttl=pg_ttl)
  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()

    # drop rows if that option selected
    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()
    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"

    return render_template('disp_image.html', n_whl=g.n_whl, ln_sz=g.ln_w, shape=t_shp,
       sp_img=data, type='gnarly', pttl=pg_ttl, d_end=d_end, d_nbr=g.r_skp)

Done

I am not going to show you any images, will leave that for the next post. As things stand, the gnarly plots are all painted with the default tab10 colour map. Not so pretty.

I did deploy to production/cloud. No need to repeat that stuff in every post.

So, in the next post, I am going to sort adding in the colour maps I was using in my initial code development. Likely add a form field to allow the user to select a colour map or have one randomly selected.

I also want to add a form option allowing the user to chose to reuse the existing base curve data. That way they should able to change some parameters to see how that affects the final image. E.G. number of rows dropped for a gnarly image, or the colour map for any image.

I will try to add coloured backgrounds as well. But, that may have to wait for another post depending on how much coding is required to get the above additions working.

Resources