code.py
  1  # SPDX-FileCopyrightText: 2021 Phil Burgess for Adafruit Industries
  2  #
  3  # SPDX-License-Identifier: MIT
  4  
  5  """
  6  MOVE-AND-BLINK EYES for Adafruit EyeLights (LED Glasses + Driver).
  7  
  8  I'd written a very cool squash-and-stretch effect for the eye movement,
  9  but unfortunately the resolution and frame rate are such that the pupils
 10  just look like circles regardless. I'm keeping it in despite the added
 11  complexity, because CircuitPython devices WILL get faster, LED matrix
 12  densities WILL improve, and this way the code won't require a re-write
 13  at such a later time. It's a really adorable effect with enough pixels.
 14  """
 15  
 16  import math
 17  import random
 18  import time
 19  from supervisor import reload
 20  import board
 21  from busio import I2C
 22  import adafruit_is31fl3741
 23  from adafruit_is31fl3741.adafruit_ledglasses import LED_Glasses
 24  
 25  
 26  # CONFIGURABLES ------------------------
 27  
 28  eye_color = (255, 128, 0)  #      Amber pupils
 29  ring_open_color = (75, 75, 75)  # Color of LED rings when eyes open
 30  ring_blink_color = (50, 25, 0)  # Color of LED ring "eyelid" when blinking
 31  
 32  radius = 3.4  # Size of pupil (3X because of downsampling later)
 33  
 34  # Reading through the code, you'll see a lot of references to this "3X"
 35  # space. What it's referring to is a bitmap that's 3 times the resolution
 36  # of the LED matrix (i.e. 15 pixels tall instead of 5), which gets scaled
 37  # down to provide some degree of antialiasing. It's why the pupils have
 38  # soft edges and can make fractional-pixel motions.
 39  # Because of the way the downsampling is done, the eyelid edge when drawn
 40  # across the eye will always be the same hue as the pupils, it can't be
 41  # set independently like the ring blink color.
 42  
 43  gamma = 2.6  # For color adjustment. Leave as-is.
 44  
 45  
 46  # CLASSES & FUNCTIONS ------------------
 47  
 48  
 49  class Eye:
 50      """Holds per-eye positional data; each covers a different area of the
 51      overall LED matrix."""
 52  
 53      def __init__(self, left, xoff):
 54          self.left = left  #     Leftmost column on LED matrix
 55          self.x_offset = xoff  # Horizontal offset (3X space) to fixate
 56  
 57      def smooth(self, data, rect):
 58          """Scale bitmap (in 'data') to LED array, with smooth 1:3
 59          downsampling. 'rect' is a 4-tuple rect of which pixels get
 60          filtered (anything outside is cleared to 0), saves a few cycles."""
 61          # Quantize bounds rect from 3X space to LED matrix space.
 62          rect = (
 63              rect[0] // 3,  #       Left
 64              rect[1] // 3,  #       Top
 65              (rect[2] + 2) // 3,  # Right
 66              (rect[3] + 2) // 3,  # Bottom
 67          )
 68          for y in range(rect[1]):  # Erase rows above top
 69              for x in range(6):
 70                  glasses.pixel(self.left + x, y, 0)
 71          for y in range(rect[1], rect[3]):  #  Each row, top to bottom...
 72              pixel_sum = bytearray(6)  #  Initialize row of pixel sums to 0
 73              for y1 in range(3):  # 3 rows of bitmap...
 74                  row = data[y * 3 + y1]  # Bitmap data for current row
 75                  for x in range(rect[0], rect[2]):  # Column, left to right
 76                      x3 = x * 3
 77                      # Accumulate 3 pixels of bitmap into pixel_sum
 78                      pixel_sum[x] += row[x3] + row[x3 + 1] + row[x3 + 2]
 79              # 'pixel_sum' will now contain values from 0-9, indicating the
 80              # number of set pixels in the corresponding section of the 3X
 81              # bitmap. 'colormap' expands the sum to 24-bit RGB space.
 82              for x in range(rect[0]):  # Erase any columns to left
 83                  glasses.pixel(self.left + x, y, 0)
 84              for x in range(rect[0], rect[2]):  # Column, left to right
 85                  glasses.pixel(self.left + x, y, colormap[pixel_sum[x]])
 86              for x in range(rect[2], 6):  # Erase columns to right
 87                  glasses.pixel(self.left + x, y, 0)
 88          for y in range(rect[3], 5):  # Erase rows below bottom
 89              for x in range(6):
 90                  glasses.pixel(self.left + x, y, 0)
 91  
 92  
 93  # pylint: disable=too-many-locals
 94  def rasterize(data, point1, point2, rect):
 95      """Rasterize an arbitrary ellipse into the 'data' bitmap (3X pixel
 96      space), given foci point1 and point2 and with area determined by global
 97      'radius' (when foci are same point; a circle). Foci and radius are all
 98      floating point values, which adds to the buttery impression. 'rect' is
 99      a 4-tuple rect of which pixels are likely affected. Data is assumed 0
100      before arriving here; no clearing is performed."""
101  
102      dx = point2[0] - point1[0]
103      dy = point2[1] - point1[1]
104      d2 = dx * dx + dy * dy  # Dist between foci, squared
105      if d2 <= 0:
106          # Foci are in same spot - it's a circle
107          perimeter = 2 * radius
108          d = 0
109      else:
110          # Foci are separated - it's an ellipse.
111          d = d2 ** 0.5  # Distance between foci
112          c = d * 0.5  # Center-to-foci distance
113          # This is an utterly brute-force way of ellipse-filling based on
114          # the "two nails and a string" metaphor...we have the foci points
115          # and just need the string length (triangle perimeter) to yield
116          # an ellipse with area equal to a circle of 'radius'.
117          # c^2 = a^2 - b^2  <- ellipse formula
118          #   a = r^2 / b    <- substitute
119          # c^2 = (r^2 / b)^2 - b^2
120          # b = sqrt(((c^2) + sqrt((c^4) + 4 * r^4)) / 2)  <- solve for b
121          b2 = ((c ** 2) + (((c ** 4) + 4 * (radius ** 4)) ** 0.5)) * 0.5
122          # By my math, perimeter SHOULD be...
123          # perimeter = d + 2 * ((b2 + (c ** 2)) ** 0.5)
124          # ...but for whatever reason, working approach here is really...
125          perimeter = d + 2 * (b2 ** 0.5)
126  
127      # Like I'm sure there's a way to rasterize this by spans rather than
128      # all these square roots on every pixel, but for now...
129      for y in range(rect[1], rect[3]):  # For each row...
130          y5 = y + 0.5  #         Pixel center
131          dy1 = y5 - point1[1]  # Y distance from pixel to first point
132          dy2 = y5 - point2[1]  # " to second
133          dy1 *= dy1  # Y1^2
134          dy2 *= dy2  # Y2^2
135          for x in range(rect[0], rect[2]):  # For each column...
136              x5 = x + 0.5  #         Pixel center
137              dx1 = x5 - point1[0]  # X distance from pixel to first point
138              dx2 = x5 - point2[0]  # " to second
139              d1 = (dx1 * dx1 + dy1) ** 0.5  # 2D distance to first point
140              d2 = (dx2 * dx2 + dy2) ** 0.5  # " to second
141              if (d1 + d2 + d) <= perimeter:
142                  data[y][x] = 1  # Point is inside ellipse
143  
144  
145  def gammify(color):
146      """Given an (R,G,B) color tuple, apply gamma correction and return
147      a packed 24-bit RGB integer."""
148      rgb = [int(((color[x] / 255) ** gamma) * 255 + 0.5) for x in range(3)]
149      return (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]
150  
151  
152  def interp(color1, color2, blend):
153      """Given two (R,G,B) color tuples and a blend ratio (0.0 to 1.0),
154      interpolate between the two colors and return a gamma-corrected
155      in-between color as a packed 24-bit RGB integer. No bounds clamping
156      is performed on blend value, be nice."""
157      inv = 1.0 - blend  # Weighting of second color
158      return gammify([color1[x] * blend + color2[x] * inv for x in range(3)])
159  
160  
161  # HARDWARE SETUP -----------------------
162  
163  # Manually declare I2C (not board.I2C() directly) to access 1 MHz speed...
164  i2c = I2C(board.SCL, board.SDA, frequency=1000000)
165  
166  # Initialize the IS31 LED driver, buffered for smoother animation
167  glasses = LED_Glasses(i2c, allocate=adafruit_is31fl3741.MUST_BUFFER)
168  glasses.show()  # Clear any residue on startup
169  glasses.global_current = 20  # Just middlin' bright, please
170  
171  
172  # INITIALIZE TABLES & OTHER GLOBALS ----
173  
174  # This table is for mapping 3x3 averaged bitmap values (0-9) to
175  # RGB colors. Avoids a lot of shift-and-or on every pixel.
176  colormap = []
177  for n in range(10):
178      colormap.append(gammify([n / 9 * eye_color[x] for x in range(3)]))
179  
180  # Pre-compute the Y position of 1/2 of the LEDs in a ring, relative
181  # to the 3X bitmap resolution, so ring & matrix animation can be aligned.
182  y_pos = []
183  for n in range(13):
184      angle = n / 24 * math.pi * 2
185      y_pos.append(10 - math.cos(angle) * 12)
186  
187  # Pre-compute color of LED ring in fully open (unblinking) state
188  ring_open_color_packed = gammify(ring_open_color)
189  
190  # A single pre-computed scanline of "eyelid edge during blink" can be
191  # stuffed into the 3X raster as needed, avoids setting pixels manually.
192  eyelid = (
193      b"\x01\x01\x00\x01\x01\x00\x01\x01\x00" b"\x01\x01\x00\x01\x01\x00\x01\x01\x00"
194  )  # 2/3 of pixels set
195  
196  # Initialize eye position and move/blink animation timekeeping
197  cur_pos = next_pos = (9, 7.5)  # Current, next eye position in 3X space
198  in_motion = False  #             True = eyes moving, False = eyes paused
199  blink_state = 0  #               0, 1, 2 = unblinking, closing, opening
200  move_start_time = move_duration = blink_start_time = blink_duration = 0
201  
202  # Two eye objects. The first starts at column 1 of the matrix with its
203  # pupil offset by +2 (in 3X space), second at column 11 with -2 offset.
204  # The offsets make the pupils fixate slightly (converge on a point), so
205  # the two pupils aren't always aligned the same on the pixel grid, which
206  # would be conspicuously pixel-y.
207  eyes = [Eye(1, 2), Eye(11, -2)]
208  
209  frames, start_time = 0, time.monotonic()  # For frames/second calculation
210  
211  
212  # MAIN LOOP ----------------------------
213  
214  while True:
215      # The try/except here is because VERY INFREQUENTLY the I2C bus will
216      # encounter an error when accessing the LED driver, whether from bumping
217      # around the wires or sometimes an I2C device just gets wedged. To more
218      # robustly handle the latter, the code will restart if that happens.
219      try:
220  
221          # The eye animation logic is a carry-over from like a billion
222          # prior eye projects, so this might be comment-light.
223          now = time.monotonic()  # 'Snapshot' the time once per frame
224  
225          # Blink logic
226          elapsed = now - blink_start_time  # Time since start of blink event
227          if elapsed > blink_duration:  #     All done with event?
228              blink_start_time = now  #       A new one starts right now
229              elapsed = 0
230              blink_state += 1  #             Cycle closing/opening/paused
231              if blink_state == 1:  #         Starting new blink...
232                  blink_duration = random.uniform(0.06, 0.12)
233              elif blink_state == 2:  #       Switching closing to opening...
234                  blink_duration *= 2  #      Opens at half the speed
235              else:  #                        Switching to pause in blink
236                  blink_state = 0
237                  blink_duration = random.uniform(0.5, 4)
238          if blink_state:  #                  If currently in a blink...
239              ratio = elapsed / blink_duration  # 0.0-1.0 as it closes
240              if blink_state == 2:
241                  ratio = 1.0 - ratio  #          1.0-0.0 as it opens
242              upper = ratio * 15 - 4  #       Upper eyelid pos. in 3X space
243              lower = 23 - ratio * 8  #       Lower eyelid pos. in 3X space
244  
245          # Eye movement logic. Two points, 'p1' and 'p2', are the foci of an
246          # ellipse. p1 moves from current to next position a little faster
247          # than p2, creating a "squash and stretch" effect (frame rate and
248          # resolution permitting). When motion is stopped, the two points
249          # are at the same position.
250          elapsed = now - move_start_time  # Time since start of move event
251          if in_motion:  #                   Currently moving?
252              if elapsed > move_duration:  # If end of motion reached,
253                  in_motion = False  #            Stop motion and
254                  p1 = p2 = cur_pos = next_pos  # Set to new position
255                  move_duration = random.uniform(0.5, 1.5)  # Wait this long
256              else:  # Still moving
257                  # Determine p1, p2 position in time
258                  delta = (next_pos[0] - cur_pos[0], next_pos[1] - cur_pos[1])
259                  ratio = elapsed / move_duration
260                  if ratio < 0.6:  # First 60% of move time
261                      # p1 is in motion
262                      # Easing function: 3*e^2-2*e^3 0.0 to 1.0
263                      e = ratio / 0.6  # 0.0 to 1.0
264                      e = 3 * e * e - 2 * e * e * e
265                      p1 = (cur_pos[0] + delta[0] * e, cur_pos[1] + delta[1] * e)
266                  else:  # Last 40% of move time
267                      p1 = next_pos  # p1 has reached end position
268                  if ratio > 0.3:  # Last 60% of move time
269                      # p2 is in motion
270                      e = (ratio - 0.3) / 0.7  #       0.0 to 1.0
271                      e = 3 * e * e - 2 * e * e * e  # Easing func.
272                      p2 = (cur_pos[0] + delta[0] * e, cur_pos[1] + delta[1] * e)
273                  else:  # First 40% of move time
274                      p2 = cur_pos  # p2 waits at start position
275          else:  # Eye is stopped
276              p1 = p2 = cur_pos  # Both foci at current eye position
277              if elapsed > move_duration:  # Pause time expired?
278                  in_motion = True  #        Start up new motion!
279                  move_start_time = now
280                  move_duration = random.uniform(0.15, 0.25)
281                  angle = random.uniform(0, math.pi * 2)
282                  dist = random.uniform(0, 7.5)
283                  next_pos = (
284                      9 + math.cos(angle) * dist,
285                      7.5 + math.sin(angle) * dist * 0.8,
286                  )
287  
288          # Draw the raster part of each eye...
289          for eye in eyes:
290              # Allocate/clear the 3X bitmap buffer
291              bitmap = [bytearray(6 * 3) for _ in range(5 * 3)]
292              # Each eye's foci are offset slightly, to fixate toward center
293              p1a = (p1[0] + eye.x_offset, p1[1])
294              p2a = (p2[0] + eye.x_offset, p2[1])
295              # Compute bounding rectangle (in 3X space) of ellipse
296              # (min X, min Y, max X, max Y). Like the ellipse rasterizer,
297              # this isn't optimal, but will suffice.
298              bounds = (
299                  max(int(min(p1a[0], p2a[0]) - radius), 0),
300                  max(int(min(p1a[1], p2a[1]) - radius), 0, int(upper)),
301                  min(int(max(p1a[0], p2a[0]) + radius + 1), 18),
302                  min(int(max(p1a[1], p2a[1]) + radius + 1), 15, int(lower) + 1),
303              )
304              rasterize(bitmap, p1a, p2a, bounds)  # Render ellipse into buffer
305              # If the eye is currently blinking, and if the top edge of the
306              # eyelid overlaps the bitmap, draw a scanline across the bitmap
307              # and update the bounds rect so the whole width of the bitmap
308              # is scaled.
309              if blink_state and upper >= 0:
310                  bitmap[int(upper)] = eyelid
311                  bounds = (0, int(upper), 18, bounds[3])
312              eye.smooth(bitmap, bounds)  # 1:3 downsampling for eye
313  
314          # Matrix and rings share a few pixels. To make the rings take
315          # precedence, they're drawn later. So blink state is revisited now...
316          if blink_state:  # In mid-blink?
317              for i in range(13):  # Half an LED ring, top-to-bottom...
318                  a = min(max(y_pos[i] - upper + 1, 0), 3)
319                  b = min(max(lower - y_pos[i] + 1, 0), 3)
320                  ratio = a * b / 9  # Proximity of LED to eyelid edges
321                  packed = interp(ring_open_color, ring_blink_color, ratio)
322                  glasses.left_ring[i] = glasses.right_ring[i] = packed
323                  if 0 < i < 12:
324                      i = 24 - i  # Mirror half-ring to other side
325                      glasses.left_ring[i] = glasses.right_ring[i] = packed
326          else:
327              glasses.left_ring.fill(ring_open_color_packed)
328              glasses.right_ring.fill(ring_open_color_packed)
329  
330          glasses.show()  # Buffered mode MUST use show() to refresh matrix
331  
332      except OSError:  # See "try" notes above regarding rare I2C errors.
333          print("Restarting")
334          reload()
335  
336      frames += 1
337      elapsed = time.monotonic() - start_time
338      print(frames / elapsed)