I have decided to look at adding 2D transforms to the mosaic style images. I will apply some number of linear and rotational affine transforms to the image. Rotational transforms by themselves won’t really do much as they’d end up drawn overtop of each other. So, a linear shift seems obligatory.

In my first attempt at these transforms, I used some matplolib methods to draw the transforms. But that presented its own issues. Subsequently I used Numpy methods to do the work. A bit more effort on my part, but well worth the bit of trouble.

I don’t at the moment recall how many, but generating/drawing the transformed images is going to require a few new functions and perhaps a lengthy route function. A new route by the way. I am currently thinking /spirograph/mosaic_t, with a function named sp_mosaic_t.

Affine Transforms

I am going to allow the user to select the number of rotational transforms. Something like 3-18. Not sure how that will work with the free Google App Engine account. Will add a bit more computation and memory requirement for each image. We shall see. But for development and testing I will use 4 transforms.

Generating Transformed Data

The first function will take the base curve’s data and return the transformed data. That will hopefully allow me to reuse some of the previous methods with minimal refactoring. Given Numpy’s power, pretty straightforward.

def affine_transformation(srcx, srcy, a_tr):
  dstx = []
  dsty = []
  for i in range(len(srcx)):
    # srcx/y are python lists, convert to numpy array
    src = np.array([srcx[i], srcy[i]], dtype='float')
    # apply transform matrix, dot product
    new_points = a_tr @ src
    dstx.append(new_points[0])
    dsty.append(new_points[1])
  # convert lists of transformed x and y data to numpy arrays  
  return np.array(dstx, dtype="float"), np.array(dsty, dtype="float")

In order to generate the transformation array, I need to know the angles of all the rotations. Which of course will depend on how many of them there are. So, another function. I am using radians as that’s the default for Numpy’s methods. And a module level variable.

rd_45 = math.radians(45)


def get_angles(nbr_rots):
  tr_ang = 2 * np.pi / nbr_rots
  st_ang = tr_ang
  # want first rotation in upper left quadrant
  while st_ang > rd_45:
    st_ang = st_ang / 2
  rot_ang = [st_ang + (tr_ang * i) for i in range(nbr_rots)]
  return rot_ang

The approach I used for the linear translations is as follows.

  • get max x and y values used by the image curves, mdy
  • take a random fraction of that value, trdy = mdy * ##
  • to get the center for the related rotation, rotate (0, trdy) by the angle of the rotational transform
  • add the x and y values following the rotation to the appropriate data arrays

Most of that will be done within the routing code, at least for now. But, I did write a function to do the rotation.

def rotate_pt(px, py, angle=rd_45, cx=0, cy=0):
  a_cos = math.cos(angle)
  a_sin = math.sin(angle)
  xp_c = px - cx
  yp_c = py - cy
  x_rt = (xp_c * a_cos) - (yp_c * a_sin) + cx
  y_rt = (xp_c * a_sin) + (yp_c * a_cos) + cy
  return x_rt, y_rt

One last function. This one doesn’t have anything to do with calculating the transformed data. I started by drawing the rotations in order. But eventually decided to do them in a random order. So, I wrote a function to crank out that random order. Rather simple— but perhaps tidier code.

def get_rnd_qds(qds1):
  # remember to get copy not alias, don't want to alter previous list
  t_qds = qds1[:]
  rng.shuffle(t_qds)
  return t_qds

New Route

I will eventually add a new field to the image parameter interface form. But for now, I will just go with 4 transforms.

Some additional global variables. And function(s).

First function gets the estimated plot bounds for a given curve dataset. I am using a function because I check the min and max values for every data row in the x and y datasets. (I just copied the code from my earlier project, so please ignore the x_adj parameter.)

def get_plot_bnds(f_xs, f_ys, x_adj=0):
  x_nlim, x_xlim, y_nlim, y_xlim = 0, 0, 0, 0
  n_rws = len(f_xs)
  for i in range(n_rws):
    x_nlim = min(min(f_xs[i]), x_nlim)
    x_xlim = max(max(f_xs[i]), x_xlim)
    y_nlim = min(min(f_ys[i]), y_nlim)
    y_xlim = max(max(f_ys[i]), y_xlim)
  return x_nlim - x_adj, x_xlim + x_adj, y_nlim - x_adj, y_xlim + x_adj

And the route code.

@app.route('/spirograph/mosaic_t', methods=['GET', 'POST'])
def sp_mosaic_t():
  pg_ttl = "Mosaic Like Spirograph Image with 2D Transforms"
  if request.method == 'GET':
    f_data = {'pttl': pg_ttl, 'shapes': splt.shp_nm, 'cmaps': g.clr_opt, 'mxs': g.mx_s,
        'mntr': g.mn_tr, 'mxtr': g.mx_tr
    }
    return render_template('get_curve.html', type='mosaic', 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, 'mosaic')
    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()
    # ax.patch.set_alpha(0.01)
    g.rcm, g.cycle = sal.set_clr_map(ax, g.mx_s, g.rcm)

    # get angles
    lld_rot = sal.get_angles(g.nbr_tr)
    lld_deg = [math.degrees(ang) for ang in lld_rot]
    # get init point for linear translations
    mnx41, mxx41, mny41, mxy41 = sal.get_plot_bnds(r_xs, r_ys, x_adj=0)
    mdy = max(mxx41, mxy41)
    y_mlt = rng.choice([.875, .75, .625, .5, .375])
    trdy = mdy * y_mlt

    # drop first data row?
    if g.n_whl == 3:
      src_st = 0
    else:
      src_st = 1
    src = np.array([r_xs[src_st:], r_ys[src_st:]], dtype="float")

    # get rand quadrant order
    do_qd = list(range(g.nbr_tr))
    do_qd2 = sal.get_rnd_qds(do_qd)

    # Generate transformed datasets
    qd_data = []
    for i in do_qd:
      a = np.array([[np.cos(lld_rot[i]), -np.sin(lld_rot[i])],
                    [np.sin(lld_rot[i]), np.cos(lld_rot[i])]])

      dstx, dsty = sal.affine_transformation(src[0], src[1], a)
      dx, dy = sal.rotate_pt(0, trdy, angle=lld_rot[i], cx=0, cy=0)
      dstx = dstx + dx
      dsty = dsty + dy
      # print(f"\trot angle: {math.degrees(lld_rot[i])}, pt angle: {math.degrees(pt_rot[i])}, dx: {dx}, dy: {dy}")
      qd_data.append([dstx, dsty])

    # only change multipliers the first time, not on later loops
    sal.cnt_btw = 0

    for i in do_qd2:
      tr_xs = qd_data[i][0]
      tr_ys = qd_data[i][1]
      sal.btw_n_apart(ax, tr_xs, tr_ys, dx=g.bw_dx, ol=g.bw_o, r=g.bw_r, fix=None, mlt=g.bw_m, sect=g.bw_s, alpha=1)
      ax.autoscale()
      sal.cnt_btw += 1

    ax.text(0.5, 0.01, "© koach (barkncats) 2022", ha="center", transform=ax.transAxes,
            fontsize=10, alpha=.3, c=g.cycle[len(g.cycle)//2])
    ax.autoscale()

    bax2, _ = sal.set_bg(ax)

    # Save it to a temporary buffer.
    buf = BytesIO()
    fig.savefig(buf, format="png", bbox_inches="tight", dpi=72)
    # Embed the result in the html output.
    data = base64.b64encode(buf.getbuffer()).decode("ascii")

    c_data = sal.get_curve_dtl()
    i_data = sal.get_image_dtl(pg_ttl)
    p_data = {'bdx': g.bw_dx, 'bol': g.bw_o, 'bcs': g.bw_s,
              'bmlt': g.bw_m, 'mlts': sal.c_mlts,
              'ntr': g.nbr_tr, 'trang': lld_deg, 'trpt': f'(0, {round(trdy, 3)}) ({y_mlt})',
              'trord': do_qd2
             }

    return render_template('disp_image.html', sp_img=data, type='mosaic', c_data=c_data, i_data = i_data, p_data=p_data)

I am thinking some refactoring is in order. A few new functions should help. But, first let’s add that new field to the curve parameter form.

Refactor Curve Parameter Form

The global variables I added were:

# transform related vars
mn_tr = 3     # min number of transforms
mx_tr = 18    # max number of transforms
nbr_tr = 4   # number of rotational transformations

The form will be used to set the value for nbr_tr. I added the following to the bottom of the form in the template get_curve.html.

    {% if 'mntr' in f_data %}
    <div>
      <p style="margin-left:1rem;margin-top:0;font-size:110%;"><b>2D Transforms (Rotational + Linear)</b></p>
      <div>
        <label for="ntr">Number of transforms: </label><br>
        <input type="text" name="ntr" placeholder="'r' for random, or number {{ f_data['mntr'] }}-{{ f_data['mxtr'] }} inclusive"/>
      </div>
    </div>  
    {% endif %}

And in sp_app_lib.proc_curve_form() I added some code to handle the new field. After the code handling the colour fields and before the code specific to gnarly type curves.

    if 'ntr' in f_data:
      if f_data['ntr'].isnumeric():
        t_ntr = int(f_data['ntr'])
        if t_ntr >= g.mn_tr and t_ntr <= g.mx_tr:
          g.nbr_tr = t_ntr
        # else no chg
      elif f_data['ntr'].lower() == 'r':
        g.nbr_tr = rng.integers(g.mn_tr, g.mx_tr)

    if i_typ == 'gnarly':
... ...

Examples

I converted the images to JPEG at 67% compression to save on the pages bandwidth. Would have required many times more bytes with PNG images. As in the previous post.

Curve Parameters
    Wheels: 12 wheels (shape(s): rhombus (r))
    Symmetry: k_fold = 4, congruency = 2
    Frequencies: [6, 18, 2, 10, -6, -10, 6, 10, 2, -6, 2, 18]
    Widths: [1, 0.7024617958184918, 0.5373063556098061j, 0.504807330598898, 0.2555269738267228, 0.17173890848301623, 0.14489734026061388j, 0.125j, 0.125j, 0.125, 0.125j, 0.125]
    Heights: [1, 0.549946135963451j, 0.5482816219270289j, 0.30769529402676415, 0.23455291057675048j, 0.157304722143189j, 0.125, 0.125j, 0.125, 0.125, 0.125, 0.125]

Image Type Parameters
    Colouring: between adjacent datarows with overlap
    Sections: 61 colour sections per datarow pairing

Drawing Parameters
    Colour map: rainbow
    BG colour map: turbo
    Line width (if used): None

Transformations
    Number of transforms: 7
    Angles: [25.714285714285715, 77.14285714285714, 128.57142857142858, 180.0, 231.42857142857142, 282.85714285714283, 334.2857142857143]
    Order: [3, 2, 6, 0, 1, 4, 5]
    Base linear translation point: (0, 1.725) (0.875)

Makes me think of west coast indigenous art.

mosaic like image using 2D transforms
Curve Parameters
    Wheels: 10 wheels (shape(s): tetracuspid (t))
    Symmetry: k_fold = 8, congruency = 6
    Frequencies: [14, 30, 38, -2, -10, -18, -26, -18, -26, 14]
    Widths: [1, 0.6929355032592682, 0.6687992545983391, 0.5594322945124343, 0.3212345078031467, 0.3048750735230951, 0.24262540007665565, 0.1598007565465859j, 0.125, 0.125]
    Heights: [1, 0.5242505147398607, 0.27143112363550687, 0.1420110019970596j, 0.125j, 0.125j, 0.125, 0.125, 0.125, 0.125]

Image Type Parameters
    Colouring: between adjacent datarows with overlap
    Sections: 62 colour sections per datarow pairing

Drawing Parameters
    Colour map: bone
    BG colour map: cividis
    Line width (if used): None

Transformations
    Number of transforms: 7
    Angles: [25.714285714285715, 77.14285714285714, 128.57142857142858, 180.0, 231.42857142857142, 282.85714285714283, 334.2857142857143]
    Order: [5, 4, 3, 6, 2, 1, 0]
    Base linear translation point: (0, 1.837) (0.875)

Middle eastern geometric imagery?

mosaic like image using 2D transforms

The remainder all use multipliers which appears to skew things considerably.

Curve Parameters
    Wheels: 10 wheels (shape(s): ellipse (e))
    Symmetry: k_fold = 7, congruency = 4
    Frequencies: [11, 25, 18, 25, -10, 11, -17, -24, 11, 32]
    Widths: [1, 0.5617347737320201, 0.30584295813282814j, 0.20340383525799205, 0.17659189687002594, 0.125, 0.125, 0.125, 0.125j, 0.125]
    Heights: [1, 0.6076107431523396, 0.39361256537243894, 0.3745299183357966, 0.3671978084731096, 0.2940575082869049, 0.16149169524701482, 0.14099465665227723, 0.125, 0.125]

Image Type Parameters
    Colouring: between adjacent datarows with overlap
    Sections: 24 colour sections per datarow pairing
    Multipliers: [1.8, 1.6, 1.4, 1.4, 1.5, 1.0, 1.3, 1.3, 1.0]

Drawing Parameters
    Colour map: inferno
    BG colour map: Greys
    Line width (if used): None

Transformations
    Number of transforms: 6
    Angles: [29.999999999999996, 90.0, 149.99999999999997, 210.0, 270.0, 330.0]
    Order: [5, 0, 3, 4, 2, 1]
    Base linear translation point: (0, 1.642) (0.625)
mosaic like image using 2D transforms
Curve Parameters
    Wheels: 5 wheels (shape(s): tetracuspid (t))
    Symmetry: k_fold = 2, congruency = 1
    Frequencies: [1, 1, 3, -1, 3]
    Widths: [1, 0.561044923904825, 0.3578867065151112, 0.3076901881741516j, 0.19414148029590605]
    Heights: [1, 0.5466306349074822, 0.3888512095372273j, 0.3707025393355153, 0.288516972650256j]

Image Type Parameters
    Colouring: between adjacent datarows with overlap
    Sections: 44 colour sections per datarow pairing
    Multipliers: [1.6, 1.4, 1.3, 1.1]

Drawing Parameters
    Colour map: magma
    BG colour map: bone
    Line width (if used): None

Transformations
    Number of transforms: 5
    Angles: [36.0, 108.0, 180.0, 252.0, 324.0]
    Order: [2, 4, 0, 1, 3]
    Base linear translation point: (0, 0.605) (0.5)
mosaic like image using 2D transforms
Curve Parameters
    Wheels: 4 wheels (shape(s): ['e', 'e', 'c', 's'])
    Symmetry: k_fold = 3, congruency = 2
    Frequencies: [-1, 11, -4, 5]
    Widths: [1, 0.6115908489197277j, 0.5955385614160326, 0.4618608232563048]
    Heights: [1, 0.6510049477927171, 0.4307331185113737, 0.28748770321252837]

Image Type Parameters
    Colouring: between adjacent datarows with overlap
    Sections: 32 colour sections per datarow pairing
    Multipliers: [2.4, 1.0, 1.4]

Drawing Parameters
    Colour map: gnuplot
    BG colour map: cividis
    Line width (if used): None

Transformations
    Number of transforms: 5
    Angles: [36.0, 108.0, 180.0, 252.0, 324.0]
    Order: [1, 4, 3, 2, 0]
    Base linear translation point: (0, 0.55) (0.375)
mosaic like image using 2D transforms
Curve Parameters
    Wheels: 11 wheels (shape(s): ['c', 't', 't', 's', 'r', 'q', 't', 't', 'q', 'r', 'c'])
    Symmetry: k_fold = 4, congruency = 2
    Frequencies: [2, -14, -6, -6, 2, -2, -14, -6, 10, 14, 6]
    Widths: [1, 0.6797152826352928j, 0.35898719517836136, 0.33371033936805855, 0.21239913716735612, 0.21050337599639382, 0.20482751917331649j, 0.1365750765854171, 0.1345293622071587j, 0.125, 0.125]
    Heights: [1, 0.6062509331672192, 0.35752405062679454j, 0.2720942121783654, 0.22722771045899226j, 0.15866955863845744, 0.125, 0.125, 0.125j, 0.125, 0.125j]

Image Type Parameters
    Colouring: between adjacent datarows with overlap
    Sections: 63 colour sections per datarow pairing
    Multipliers: [1.6, 1.6, 2.0, 2.2, 1.2, 1.1, 1.6, 1.4, 1.3, 1.4]

Drawing Parameters
    Colour map: jet
    BG colour map: bone
    Line width (if used): None

Transformations
    Number of transforms: 5
    Angles: [36.0, 108.0, 180.0, 252.0, 324.0]
    Order: [2, 1, 4, 0, 3]
    Base linear translation point: (0, 0.88) (0.5)
mosaic like image using 2D transforms

But not the worst images I have ever seen or produced.

Refactor Route Code

Okay, decided to move the code for converting maplotlib figure to base64 into a function and call that from the routes. In the latest route, I was creating a huge array with all the transforms stored in it. Then plotting them in the desired order. That really is not necessary, I can simply generate the desired transform within the plotting loop at the point where it is needed. So, did just that.

That spirograph/mosaic_t route now looks like this.

@app.route('/spirograph/mosaic_t', methods=['GET', 'POST'])
def sp_mosaic_t():
  pg_ttl = "Mosaic Like Spirograph Image with 2D Transforms"
  if request.method == 'GET':
    f_data = {'pttl': pg_ttl, 'shapes': splt.shp_nm, 'cmaps': g.clr_opt, 'mxs': g.mx_s,
        'mntr': g.mn_tr, 'mxtr': g.mx_tr
    }
    return render_template('get_curve.html', type='mosaic', f_data=f_data)
  elif request.method == 'POST':
    # print(f"POST[shape]: {request.form.get('shape')}, POST[shp_mlt]: {request.form.get('shp_mlt')}")

    g.DEBUG = True

    sal.proc_curve_form(request.form, 'mosaic')
    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()
    # ax.patch.set_alpha(0.01)
    g.rcm, g.cycle = sal.set_clr_map(ax, g.mx_s, g.rcm)

    # get angles
    lld_rot = sal.get_angles(g.nbr_tr)
    lld_deg = [math.degrees(ang) for ang in lld_rot]
    # get init point for linear translations
    mnx41, mxx41, mny41, mxy41 = sal.get_plot_bnds(r_xs, r_ys, x_adj=0)
    mdy = max(mxx41, mxy41)
    y_mlt = rng.choice([.875, .75, .625, .5, .375])
    trdy = mdy * y_mlt

    # drop first data row?
    if g.n_whl == 3:
      src_st = 0
    else:
      src_st = 1
    src = np.array([r_xs[src_st:], r_ys[src_st:]], dtype="float")

    # get rand quadrant order
    do_qd = list(range(g.nbr_tr))
    do_qd2 = sal.get_rnd_qds(do_qd)

    # only change multipliers the first time, not on later loops
    sal.cnt_btw = 0

    # Generate transformed datasets
    for i in do_qd2:
      a = np.array([[np.cos(lld_rot[i]), -np.sin(lld_rot[i])],
                    [np.sin(lld_rot[i]), np.cos(lld_rot[i])]])

      dstx, dsty = sal.affine_transformation(src[0], src[1], a)
      dx, dy = sal.rotate_pt(0, trdy, angle=lld_rot[i], cx=0, cy=0)
      dstx = dstx + dx
      dsty = dsty + dy

      sal.btw_n_apart(ax, dstx, dsty, dx=g.bw_dx, ol=g.bw_o, r=g.bw_r, fix=None, mlt=g.bw_m, sect=g.bw_s)
      ax.autoscale()
      sal.cnt_btw += 1

    ax.text(0.5, 0.01, "© koach (barkncats) 2022", ha="center", transform=ax.transAxes,
            fontsize=10, alpha=.3, c=g.cycle[len(g.cycle)//2])
    ax.autoscale()

    bax2, _ = sal.set_bg(ax)

    # convert image to base64 for embedding in html page
    data = sal.fig_2_base64(fig)

    c_data = sal.get_curve_dtl()
    i_data = sal.get_image_dtl(pg_ttl)
    p_data = {'bdx': g.bw_dx, 'bol': g.bw_o, 'bcs': g.bw_s,
              'bmlt': g.bw_m, 'mlts': sal.c_mlts,
              'ntr': g.nbr_tr, 'trang': lld_deg, 'trpt': f'(0, {round(trdy, 3)}) ({y_mlt})',
              'trord': do_qd2
             }

    return render_template('disp_image.html', sp_img=data, type='mosaic', c_data=c_data, i_data = i_data, p_data=p_data)

Done

That’s it for this one I think. Plenty of babbling, lots of code, and a generous number of images.

Will tackle adding transforms to the basic and gnarly images next time. I will as well, quite likely move the transform generation code into a separate function. Expect that will be repeated in the next set of image routes. Similarly for the get angles and initial linear transformation point. But for now…

Until then, play hard, code harder.