|
| 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. |
| 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 (e.g. 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 | + dy1 = y - point1[1] # Y distance from pixel to first point |
| 131 | + dy2 = y - point2[1] # " to second |
| 132 | + dy1 *= dy1 # Y1^2 |
| 133 | + dy2 *= dy2 # Y2^2 |
| 134 | + for x in range(rect[0], rect[2]): # For each column... |
| 135 | + dx1 = x - point1[0] # X distance from pixel to first point |
| 136 | + dx2 = x - point2[0] # " to second |
| 137 | + d1 = (dx1 * dx1 + dy1) ** 0.5 # 2D distance to first point |
| 138 | + d2 = (dx2 * dx2 + dy2) ** 0.5 # " to second |
| 139 | + if (d1 + d2 + d) <= perimeter: |
| 140 | + data[y][x] = 1 # Point is inside ellipse |
| 141 | + |
| 142 | + |
| 143 | +def gammify(color): |
| 144 | + """Given an (R,G,B) color tuple, apply gamma correction and return |
| 145 | + a packed 24-bit RGB integer.""" |
| 146 | + rgb = [int(((color[x] / 255) ** gamma) * 255 + 0.5) for x in range(3)] |
| 147 | + return (rgb[0] << 16) | (rgb[1] << 8) | rgb[2] |
| 148 | + |
| 149 | + |
| 150 | +def interp(color1, color2, blend): |
| 151 | + """Given two (R,G,B) color tuples and a blend ratio (0.0 to 1.0), |
| 152 | + interpolate between the two colors and return a gamma-corrected |
| 153 | + in-between color as a packed 24-bit RGB integer. No bounds clamping |
| 154 | + is performed on blend value, be nice.""" |
| 155 | + inv = 1.0 - blend # Weighting of second color |
| 156 | + return gammify([color1[x] * blend + color2[x] * inv for x in range(3)]) |
| 157 | + |
| 158 | + |
| 159 | +# HARDWARE SETUP ----------------------- |
| 160 | + |
| 161 | +# Manually declare I2C (not board.I2C() directly) to access 1 MHz speed... |
| 162 | +i2c = I2C(board.SCL, board.SDA, frequency=1000000) |
| 163 | + |
| 164 | +# Initialize the IS31 LED driver, buffered for smoother animation |
| 165 | +glasses = LED_Glasses(i2c, allocate=adafruit_is31fl3741.MUST_BUFFER) |
| 166 | +glasses.show() # Clear any residue on startup |
| 167 | +glasses.global_current = 20 # Just middlin' bright, please |
| 168 | + |
| 169 | + |
| 170 | +# INITIALIZE TABLES & OTHER GLOBALS ---- |
| 171 | + |
| 172 | +# This table is for mapping 3x3 averaged bitmap values (0-9) to |
| 173 | +# RGB colors. Avoids a lot of shift-and-or on every pixel. |
| 174 | +colormap = [] |
| 175 | +for n in range(10): |
| 176 | + colormap.append(gammify([n / 9 * eye_color[x] for x in range(3)])) |
| 177 | + |
| 178 | +# Pre-compute the Y position of 1/2 of the LEDs in a ring, relative |
| 179 | +# to the 3X bitmap resolution, so ring & matrix animation can be aligned. |
| 180 | +y_pos = [] |
| 181 | +for n in range(13): |
| 182 | + angle = n / 24 * math.pi * 2 |
| 183 | + y_pos.append(10 - math.cos(angle) * 12) |
| 184 | + |
| 185 | +# Pre-compute color of LED ring in fully open (unblinking) state |
| 186 | +ring_open_color_packed = gammify(ring_open_color) |
| 187 | + |
| 188 | +# A single pre-computed scanline of "eyelid edge during blink" can be |
| 189 | +# stuffed into the 3X raster as needed, avoids setting pixels manually. |
| 190 | +eyelid = ( |
| 191 | + b"\x01\x01\x00\x01\x01\x00\x01\x01\x00" b"\x01\x01\x00\x01\x01\x00\x01\x01\x00" |
| 192 | +) # 2/3 of pixels set |
| 193 | + |
| 194 | +# Initialize eye position and move/blink animation timekeeping |
| 195 | +cur_pos = next_pos = (9, 7.5) # Current, next eye position in 3X space |
| 196 | +in_motion = False # True = eyes moving, False = eyes paused |
| 197 | +blink_state = 0 # 0, 1, 2 = unblinking, closing, opening |
| 198 | +move_start_time = move_duration = blink_start_time = blink_duration = 0 |
| 199 | + |
| 200 | +# Two eye objects. The first starts at column 1 of the matrix with its |
| 201 | +# pupil offset by +2 (in 3X space), second at column 11 with -2 offset. |
| 202 | +# The offsets make the pupils fixate slightly (converge on a point), so |
| 203 | +# the two pupils aren't always aligned the same on the pixel grid, which |
| 204 | +# would be conspicuously pixel-y. |
| 205 | +eyes = [Eye(1, 2), Eye(11, -2)] |
| 206 | + |
| 207 | +frames, start_time = 0, time.monotonic() # For frames/second calculation |
| 208 | + |
| 209 | + |
| 210 | +# MAIN LOOP ---------------------------- |
| 211 | + |
| 212 | +while True: |
| 213 | + # The try/except here is because VERY INFREQUENTLY the I2C bus will |
| 214 | + # encounter an error when accessing the LED driver, whether from bumping |
| 215 | + # around the wires or sometimes an I2C device just gets wedged. To more |
| 216 | + # robustly handle the latter, the code will restart if that happens. |
| 217 | + try: |
| 218 | + |
| 219 | + # The eye animation logic is a carry-over from like a billion |
| 220 | + # prior eye projects, so this might be comment-light. |
| 221 | + now = time.monotonic() # 'Snapshot' the time once per frame |
| 222 | + |
| 223 | + # Blink logic |
| 224 | + elapsed = now - blink_start_time # Time since start of blink event |
| 225 | + if elapsed > blink_duration: # All done with event? |
| 226 | + blink_start_time = now # A new one starts right now |
| 227 | + elapsed = 0 |
| 228 | + blink_state += 1 # Cycle closing/opening/paused |
| 229 | + if blink_state == 1: # Starting new blink... |
| 230 | + blink_duration = random.uniform(0.06, 0.12) |
| 231 | + elif blink_state == 2: # Switching closing to opening... |
| 232 | + blink_duration *= 2 # Opens at half the speed |
| 233 | + else: # Switching to pause in blink |
| 234 | + blink_state = 0 |
| 235 | + blink_duration = random.uniform(0.5, 4) |
| 236 | + if blink_state: # If currently in a blink... |
| 237 | + ratio = elapsed / blink_duration # 0.0-1.0 as it closes |
| 238 | + if blink_state == 2: |
| 239 | + ratio = 1.0 - ratio # 1.0-0.0 as it opens |
| 240 | + upper = ratio * 15 - 4 # Upper eyelid pos. in 3X space |
| 241 | + lower = 23 - ratio * 8 # Lower eyelid pos. in 3X space |
| 242 | + |
| 243 | + # Eye movement logic. Two points, 'p1' and 'p2', are the foci of an |
| 244 | + # ellipse. p1 moves from current to next position a little faster |
| 245 | + # than p2, creating a "squash and stretch" effect (frame rate and |
| 246 | + # resolution permitting). When motion is stopped, the two points |
| 247 | + # are at the same position. |
| 248 | + elapsed = now - move_start_time # Time since start of move event |
| 249 | + if in_motion: # Currently moving? |
| 250 | + if elapsed > move_duration: # If end of motion reached, |
| 251 | + in_motion = False # Stop motion and |
| 252 | + p1 = p2 = cur_pos = next_pos # Set to new position |
| 253 | + move_duration = random.uniform(0.5, 1.5) # Wait this long |
| 254 | + else: # Still moving |
| 255 | + # Determine p1, p2 position in time |
| 256 | + delta = (next_pos[0] - cur_pos[0], next_pos[1] - cur_pos[1]) |
| 257 | + ratio = elapsed / move_duration |
| 258 | + if ratio < 0.7: # First 70% of move time |
| 259 | + # p1 is in motion |
| 260 | + # Easing function: 3*e^2-2*e^3 0.0 to 1.0 |
| 261 | + e = ratio / 0.7 # 0.0 to 1.0 |
| 262 | + e = 3 * e * e - 2 * e * e * e |
| 263 | + p1 = (cur_pos[0] + delta[0] * e, cur_pos[1] + delta[1] * e) |
| 264 | + else: # Last 30% of move time |
| 265 | + p1 = next_pos # p1 has reached end position |
| 266 | + if ratio > 0.2: # Last 80% of move time |
| 267 | + # p2 is in motion |
| 268 | + e = (ratio - 0.2) / 0.8 # 0.0 to 1.0 |
| 269 | + e = 3 * e * e - 2 * e * e * e # Easing func. |
| 270 | + p2 = (cur_pos[0] + delta[0] * e, cur_pos[1] + delta[1] * e) |
| 271 | + else: # First 20% of move time |
| 272 | + p2 = cur_pos # p2 waits at start position |
| 273 | + else: # Eye is stopped |
| 274 | + p1 = p2 = cur_pos # Both foci at current eye position |
| 275 | + if elapsed > move_duration: # Pause time expired? |
| 276 | + in_motion = True # Start up new motion! |
| 277 | + move_start_time = now |
| 278 | + move_duration = random.uniform(0.15, 0.25) |
| 279 | + angle = random.uniform(0, math.pi * 2) |
| 280 | + dist = random.uniform(0, 7.5) |
| 281 | + next_pos = ( |
| 282 | + 9 + math.cos(angle) * dist, |
| 283 | + 7.5 + math.sin(angle) * dist * 0.8, |
| 284 | + ) |
| 285 | + |
| 286 | + # Draw the raster part of each eye... |
| 287 | + for eye in eyes: |
| 288 | + # Allocate/clear the 3X bitmap buffer |
| 289 | + bitmap = [bytearray(6 * 3) for _ in range(5 * 3)] |
| 290 | + # Each eye's foci are offset slightly, to fixate toward center |
| 291 | + p1a = (p1[0] + eye.x_offset, p1[1]) |
| 292 | + p2a = (p2[0] + eye.x_offset, p2[1]) |
| 293 | + # Compute bounding rectangle (in 3X space) of ellipse |
| 294 | + # (min X, min Y, max X, max Y). Like the ellipse rasterizer, |
| 295 | + # this isn't optimal, but will suffice. |
| 296 | + bounds = ( |
| 297 | + max(int(min(p1a[0], p2a[0]) - radius), 0), |
| 298 | + max(int(min(p1a[1], p2a[1]) - radius), 0, int(upper)), |
| 299 | + min(int(max(p1a[0], p2a[0]) + radius + 1), 18), |
| 300 | + min(int(max(p1a[1], p2a[1]) + radius + 1), 15, int(lower) + 1), |
| 301 | + ) |
| 302 | + rasterize(bitmap, p1a, p2a, bounds) # Render ellipse into buffer |
| 303 | + # If the eye is currently blinking, and if the top edge of the |
| 304 | + # eyelid overlaps the bitmap, draw a scanline across the bitmap |
| 305 | + # and update the bounds rect so the whole width of the bitmap |
| 306 | + # is scaled. |
| 307 | + if blink_state and upper >= 0: |
| 308 | + bitmap[int(upper)] = eyelid |
| 309 | + bounds = (0, int(upper), 18, bounds[3]) |
| 310 | + eye.smooth(bitmap, bounds) # 1:3 downsampling for eye |
| 311 | + |
| 312 | + # Matrix and rings share a few pixels. To make the rings take |
| 313 | + # precedence, they're drawn later. So blink state is revisited now... |
| 314 | + if blink_state: # In mid-blink? |
| 315 | + for i in range(13): # Half an LED ring, top-to-bottom... |
| 316 | + a = min(max(y_pos[i] - upper + 1, 0), 3) |
| 317 | + b = min(max(lower - y_pos[i] + 1, 0), 3) |
| 318 | + ratio = a * b / 9 # Proximity of LED to eyelid edges |
| 319 | + packed = interp(ring_open_color, ring_blink_color, ratio) |
| 320 | + glasses.left_ring[i] = glasses.right_ring[i] = packed |
| 321 | + if 0 < i < 12: |
| 322 | + i = 24 - i # Mirror half-ring to other side |
| 323 | + glasses.left_ring[i] = glasses.right_ring[i] = packed |
| 324 | + else: |
| 325 | + glasses.left_ring.fill(ring_open_color_packed) |
| 326 | + glasses.right_ring.fill(ring_open_color_packed) |
| 327 | + |
| 328 | + glasses.show() # Buffered mode MUST use show() to refresh matrix |
| 329 | + |
| 330 | + except OSError: # See "try" notes above regarding rare I2C errors. |
| 331 | + print("Restarting") |
| 332 | + reload() |
| 333 | + |
| 334 | + frames += 1 |
| 335 | + elapsed = time.monotonic() - start_time |
| 336 | + print(frames / elapsed) |
0 commit comments