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)