Skip to content

Commit bfa9bba

Browse files
Add EyeLights blinky eyes (CircuitPython)
1 parent 6d96c70 commit bfa9bba

3 files changed

Lines changed: 372 additions & 0 deletions

File tree

EyeLights_Blinky_Eyes/EyeLights_Blinky_Eyes/.ledglasses_nrf52840.test.only

Whitespace-only changes.
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
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+
9+
#include <Adafruit_IS31FL3741.h> // For LED driver
10+
11+
Adafruit_EyeLights_buffered glasses; // Buffered for smooth animation
12+
13+
#define GAMMA 2.6
14+
15+
// Crude error handler, prints message to Serial console, flashes LED
16+
void err(char *str, uint8_t hz) {
17+
Serial.println(str);
18+
pinMode(LED_BUILTIN, OUTPUT);
19+
for (;;) digitalWrite(LED_BUILTIN, (millis() * hz / 500) & 1);
20+
}
21+
22+
void setup() { // Runs once at program start...
23+
24+
// Initialize hardware
25+
Serial.begin(115200);
26+
if (! glasses.begin()) err("IS3741 not found", 2);
27+
28+
// Configure glasses for reduced brightness, enable output
29+
glasses.setLEDscaling(0xFF);
30+
glasses.setGlobalCurrent(20);
31+
glasses.enable(true);
32+
}
33+
34+
void loop() { // Repeat forever...
35+
glasses.show();
36+
}
Lines changed: 336 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,336 @@
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

Comments
 (0)