What I’d like to get working now is an animation showing the rotating wheels as they generate the spirograph. Expect this one will take even more time to write to file than the previous one. Adding the wheels which move in each frame makes for a lot more data to be written to the video file. In the previous simpler animation, each frame could be built by just adding some info to the previous. Not so for this one—each frame is pretty much unique.

Initial Animation Code

I tried to get things working using the modules I had from my earlier command line app. But that ran into a variety of issues. So, I added the relevant functions to sp_app_lib.py and refactored them to get things working. This included a few new module variables.

I also refactored the form processing function, proc_curve_form(), to limit the maximum number of wheels for this animation to 7. With more wheels, things, in my opinion, became too hard to visually follow. I also set the wheel shape to circles. The animation only uses rotating circles for the spirograph wheels. I could never come to grips with the mathematics for animating the rotation of other wheel shapes, e.g. tetracuspid. Won’t bother showing the changes to the function as they are pretty straightforward.

Let’s start with the new, and old, module variables.

# variables for wh_make_lns, wh_init and wh_updt
x_sp, y_sp = [], []   # the growing data for the spirograph
ln_sp = None          # the spirograph data returned to FuncAnimation by the update function
ln_rs = None          # radial lines for each circle returned to FuncAnimation by the update function
t_xs, t_ys = [], []   # wanted local copy of curve data used by previous animation
ax = None             # local copy of plot axes created in main.py
c_alpha = 1           # alpha value for the circles, not really needed at this point
c_clrs = []           # array of indices into plot colour cycle, one for each circle

Now to generate the animation, I need data for the current state of the spirograph (x_sp, y_sp above), for the circles and each circle’s radial line (line from circle center to circumference). The radial lines show where the center of the next circle is on the first or previous circle. And, in fact, are what actually make the animation visual. Without them, you wouldn’t get the sense of the circles rotating along each other’s circumference.

When the animation’s initialization function is called, it also initializes the above elements. To keep things somewhat tidy, I added functions to intialize these elements. One for the lines and one for the circles. During the initialization I am able to set things like colour, zorder, line width, alpha value, etc.

In order to draw each circle in a different colour, the animation initialization function creates a list, c_clrs, indices into the current plot’s colour cycle. Those indices are used to select the colour for each wheel. Hopefully a different colour for each. Though with some colour maps there will be very little difference between the colours of adjacent circles. Maybe I should shuffle the list?

def create_circles():
  global ax
  # get the centers of all the circles
  rs = splt.f(0)
  circles = []
  circles.append(Circle((0, 0), g.r_wds[0], color=g.cycle[0], fill=False, alpha=c_alpha, zorder=5, lw=2))
  for i in range(1, g.n_whl):
    circles.append(Circle((np.real(rs[i-1]), np.imag(rs[i-1])), g.r_wds[i], color=g.cycle[c_clrs[i]], fill=False, alpha=c_alpha, zorder=5, lw=2))
  return circles


def wh_make_lns():
  global ln_sp, ln_rs, ax
  # we don't want to create a new line object each time we run update,
  # so instantiate line object for the curve available globally
  ln_sp, = ax.plot(x_sp, y_sp, alpha=0.7, zorder=1, color=g.cycle[2])
  # add a line for the radial elements
  ln_rs, = ax.plot([], [], alpha=c_alpha, zorder=10, color="k", lw=2)

Not 100% certain why I am using global variables for the lines and not for the circles.

Now the animation initialization function. Pretty straightforward.

def wh_init():
  global ax
  # with multiple circles, need to calculate axis limits
  # perhaps could be done with autoscale
  t_lim = sum(splt.r_rad) + 0.15
  ax.set_xlim(-t_lim, t_lim)  
  ax.set_ylim(-t_lim, t_lim)
  # # get rid of axes and leftover whitespace
  ax.set_position([0, 0, 1, 1], which='both')
  # set the aspect ratio to square
  ax.set_aspect('equal')

  # get colour cycle index for each wheel
  c_jmp = len(g.cycle) // g.n_whl
  for i in range(g.n_whl):
    c_clrs.append((i * c_jmp) % len(g.cycle))

  # intialize circles
  circs = create_circles()

  # intialize the different line arrays  
  x_sp, y_sp = [], []
  wh_make_lns()
  ln_rs.set_data([], [])
  ln_sp.set_data([], [])

  # return to FuncAnimation
  return ln_sp, ln_rs, *circs

In the update function, we need to calculate the line states and circle locations for the current frame and return that info to FuncAnimation.

def wh_updt(i):
  global ax, x_sp, y_sp
  # remove all the circles shown in the previous frame
  [p.remove() for p in reversed(ax.patches)]

  # update spirograph line data for current frame
  r_pts = splt.f(i)
  x_sp.append(np.real(r_pts[-1]))  
  y_sp.append(np.imag(r_pts[-1]))
  ln_sp.set_data(x_sp, y_sp)

  # if done remove circles and radial lines from the frame
  if i == 2*np.pi:
    ln_rs.set_data([], [])
    circs = [Circle((0, 0), .1, fill=False, alpha=0) for i in range(splt.nbr_w)]
  else:
    # otherwise update radial line data and circle locations.
    r_pts = splt.f(i)
    rs_x = [0] + np.real(r_pts).tolist()
    rs_y = [0] + np.imag(r_pts).tolist()
    ln_rs.set_data(rs_x, rs_y)
    # need a new set of circles
    circs = create_circles()
    for i in range(1, splt.nbr_w):
      circs[i].center = np.real(r_pts[i-1]), np.imag(r_pts[i-1])
  for cir in circs:
    ax.add_patch(cir)
  return ln_sp, ln_rs, *circs

Hopefully that will work. But, we will need a new route to test things out.

New Route: /spirograph/ani_wheel

We will as usual have GET and POST blocks in the routing function. No real change to the GET block from the previous animation route, so not going to discuss it. The POST block begins in a similar fashion to that of ani_basic(). Collect timing data, determine the file id, process the POST data, initialize the curve, etc. I clear the plot axes and zero out some local and sp_app_lib variables. Then, assuming we do have a file id, we get down to work.

@app.route('/spirograph/ani_wheel', methods=['GET', 'POST'])
def ani_wheel():
  global ax
  purge_sessions()
  pg_ttl = "Spirograph Animation with Rotating Wheels"
  if request.method == 'GET':
    f_data = {'pttl': pg_ttl, 'shapes': splt.shp_nm, 'cxsts': g.n_whl is not None,
              'cmaps': g.clr_opt
            }
    return render_template('get_curve.html', type='ani_wheel', f_data=f_data)
  elif request.method == 'POST':
    sa_st = time.perf_counter()

    afl_id = set_fileId()

    sal.proc_curve_form(request.form, 'ani_wheel')

    t_xs, t_ys, t_shp = sal.init_curve()
    sal.t_xs, sal.t_ys = t_xs, t_ys

    sa_ds = time.perf_counter()

    c_data = sal.get_curve_dtl()
    i_data = sal.get_image_dtl(pg_ttl)

    # clear previous animation data
    ax.clear()
    sal.ax = None
    ani = None

    if afl_id is not None:
      # set colour map
      g.rcm, g.cycle = sal.set_clr_map(ax, g.lc_frq, g.rcm)

      # provide axes variable to library module
      sal.ax = ax
      # setup and generate animation
      # update function needs set of angles to generate x and y datapoints
      t = np.linspace(0, 2*np.pi, g.t_pts)
      # determine number of frames for animation
      v_frm_ratio = 8
      if v_frm_ratio == 1:
        frames = t
      else:
        frames = t[::v_frm_ratio]
        frames = np.append(frames, 2*np.pi)
      # set fps for video, pretty much guess work
      nbr_f = len(frames)
      v_ntrvl = 50 * v_frm_ratio
      mpg_fps = 30
      if nbr_f <= 129:
        mpg_fps = 4
      elif nbr_f <= 257:
        mpg_fps = 6
      elif nbr_f <= 513:
        mpg_fps = 10

      ani = FuncAnimation(fig, sal.wh_updt, frames, init_func=sal.wh_init, interval=.5, repeat=False, blit=True)

      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()

      sa_ga = time.perf_counter()
      # set background
      bax2, _ = sal.set_bg(ax)

      # save to mp4 video file
      uv_nm = f"usr_{afl_id}_{nbr_f}.mp4"
      f_pth = THIS_FOLDER / "static" / uv_nm
      writervideo = FFMpegWriter(fps=mpg_fps)
      ani.save(f_pth, writer=writervideo)

      sa_sf = time.perf_counter()

      # prevent iframe caching video file
      dttm = time.time()
      secs = int(dttm % 60)
      mins = str(int(dttm // 60))[-2:]
      hrs = str(int(dttm // 60 // 60))[-2:]

      i_data["video_nm"] = f"{uv_nm}?v={hrs}{mins}{secs}"
      p_data = {"frames": nbr_f, "interval": v_ntrvl, "file_id": afl_id}

      sa_rr = time.perf_counter()
      p_data["g_data"] = f"{(sa_ds - sa_st):0.4f} seconds"
      p_data["g_ani"] = f"{(sa_ga - sa_ds):0.4f} seconds"
      p_data["sv_ani"] = f"{(sa_sf - sa_ga):0.4f} seconds"
      p_data["t_ani"] = f"{(sa_rr - sa_st):0.4f} seconds"
    else:
      p_data = {"file_id": afl_id}

    return render_template('disp_ani.html', type='basic', c_data=c_data, i_data=i_data, p_data=p_data)

And a quick test. Here’s the final frame of the animation.

last frame of rotating wheel spirograph animation

Not exactly the smooth spirograph I wanted. I played around a bit, and finally settled on v_frm_ratio = 2. But instead of the ~500KB video file I got in the test above, the videos are now 750 KB to 1.75 MB in size. And, it takes several minutes to generate and display an animation. Not exactly user friendly. But, the final curve is definitely smoother.

And, that’s how I deployed to production. Here’s the final frame.

last frame of rotating wheel spirograph animation

Refactor

That night I did some middle of the night dreaming/thinking. I finally realized that the lack of smoothness was due to the fact that I was only drawing a point on the spirograph curve every 8th angle of the 1024 angles available. So, what if every frame, the update function generated the missing spirograph curve datapoints before returning the data to FuncAnimation?

I will need some extra information in the sp_app_lib module. The complete array of angles for one. And, the frame step size. I.E. the value of v_frm_ratio being used in the route function. For the latter I will use a module variable in the library module updated by the route function. The angle array could be generated in the module during animation initialization. The way I did things, I would also, it turns out, need to keep track of the frame count in the library module.

I added the following variables to the library module.

# variables for wh_make_lns, wh_init and wh_updt
f_step = None         # local copy of the step size for the animation
f_cnt = 0             # number of times updt function has been called
... ...
t = None              # local copy of the data values to use for animation

Let’s start with the refactored initialization function. Just a few additions and/or extra lines of code.

def wh_init():
  global ax, x_sp, y_sp, t, f_cnt
  t = np.linspace(0, 2*np.pi, g.t_pts)
  f_cnt = 0
... ...

In the update function, I added some new globals. And, replaced the code that generated the curve data with the following. Being careful to increment f_cnt on each call, which is why it is a module variable.

  global ax, x_sp, y_sp, f_cnt, t
... ...
  for j in range(f_step):
    t_ndx = (f_cnt * f_step) + j
    # don't want to exceed angle array limits
    if t_ndx >= g.t_pts:
      break
    r_pts = splt.f(t[t_ndx])
    x_sp.append(np.real(r_pts[-1]))  
    y_sp.append(np.imag(r_pts[-1]))
  ln_sp.set_data(x_sp, y_sp)
  f_cnt += 1

And, in the route function, I only really had to add one line of code. And, set the step value back to 8.

      v_frm_ratio = 8
      # need to update value in sp_app_lib.py so animation functions have access
      sal.f_step = v_frm_ratio

And, with we are back to a faster time to display and a smaller video file size. And, a smooth curve.

last frame of rotating wheel spirograph animation

That looks pretty good. And here’s a link to the video file.

If you had a look at the video file, did you see what’s wrong with the current code in the update function? Will need to fix that.

Refactor Spirograph Curve Animation

The problem boiled down to the fact that I was adding rather than subtracting—so to speak. Or, that I was extending rather than backfilling—your call. A minor adjustment sorted things nicely.

  for j in range(f_step-1, -1, -1):
    t_ndx = (f_cnt * f_step) - j
    if t_ndx >= g.t_pts or t_ndx < 0:
      continue
    r_pts = splt.f(t[t_ndx])
    x_sp.append(np.real(r_pts[-1]))  
    y_sp.append(np.imag(r_pts[-1]))
  ln_sp.set_data(x_sp, y_sp)
  f_cnt += 1

And, that did the trick.

Done

I think that’s it for this one. By the way, I did decide to shuffle the array of wheel colour indices. (I’ll let you sort that line of code.)

Until next time, remember that when things aren’t going your way, perhaps all you need to do is sleep on it.

Resources