Skip to content

Commit 8b2e3c8

Browse files
Merge pull request #1895 from PaintYourDragon/main
Add EyeLights blinky eyes, both CircuitPython and Arduino
2 parents 02ecc68 + c0034b0 commit 8b2e3c8

3 files changed

Lines changed: 664 additions & 0 deletions

File tree

EyeLights_Blinky_Eyes/EyeLights_Blinky_Eyes/.ledglasses_nrf52840.test.only

Whitespace-only changes.
Lines changed: 326 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,326 @@
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 is such that the pupils just look like
10+
circles regardless. I'm keeping it in despite the added complexity,
11+
because this WILL look great later on a bigger matrix or a TFT/OLED,
12+
and this way the hard parts won't require a re-write at such time.
13+
It's a really adorable effect with enough pixels.
14+
*/
15+
16+
#include <Adafruit_IS31FL3741.h> // For LED driver
17+
18+
// CONFIGURABLES ------------------------
19+
20+
#define RADIUS 3.4 // Size of pupil (3X because of downsampling later)
21+
22+
uint8_t eye_color[3] = { 255, 128, 0 }; // Amber pupils
23+
uint8_t ring_open_color[3] = { 75, 75, 75 }; // Color of LED rings when eyes open
24+
uint8_t ring_blink_color[3] = { 50, 25, 0 }; // Color of LED ring "eyelid" when blinking
25+
26+
// Some boards have just one I2C interface, but some have more...
27+
TwoWire *i2c = &Wire; // e.g. change this to &Wire1 for QT Py RP2040
28+
29+
// GLOBAL VARIABLES ---------------------
30+
31+
Adafruit_EyeLights_buffered glasses(true); // Buffered spex + 3X canvas
32+
GFXcanvas16 *canvas; // Pointer to canvas object
33+
34+
// Reading through the code, you'll see a lot of references to this "3X"
35+
// space. This is referring to the glasses' optional "offscreen" drawing
36+
// canvas that's 3 times the resolution of the LED matrix (i.e. 15 pixels
37+
// tall instead of 5), which gets scaled down to provide some degree of
38+
// antialiasing. It's why the pupils have soft edges and can make
39+
// fractional-pixel motions.
40+
41+
float cur_pos[2] = { 9.0, 7.5 }; // Current position of eye in canvas space
42+
float next_pos[2] = { 9.0, 7.5 }; // Next position "
43+
bool in_motion = false; // true = eyes moving, false = eyes paused
44+
uint8_t blink_state = 0; // 0, 1, 2 = unblinking, closing, opening
45+
uint32_t move_start_time = 0; // For animation timekeeping
46+
uint32_t move_duration = 0;
47+
uint32_t blink_start_time = 0;
48+
uint32_t blink_duration = 0;
49+
float y_pos[13]; // Coords of LED ring pixels in canvas space
50+
uint32_t ring_open_color_packed; // ring_open_color[] as packed RGB integer
51+
uint16_t eye_color565; // eye_color[] as a GFX packed '565' value
52+
uint32_t frames = 0; // For frames-per-second calculation
53+
uint32_t start_time;
54+
55+
// These offsets position each pupil on the canvas grid and make them
56+
// fixate slightly (converge on a point) so they're not always aligned
57+
// the same on the pixel grid, which would be conspicuously pixel-y.
58+
float x_offset[2] = { 5.0, 31.0 };
59+
// These help perform x-axis clipping on the rasterized ellipses,
60+
// so they don't "bleed" outside the rings and require erasing.
61+
int box_x_min[2] = { 3, 33 };
62+
int box_x_max[2] = { 21, 51 };
63+
64+
#define GAMMA 2.6 // For color correction, shouldn't need changing
65+
66+
67+
// HELPER FUNCTIONS ---------------------
68+
69+
// Crude error handler, prints message to Serial console, flashes LED
70+
void err(char *str, uint8_t hz) {
71+
Serial.println(str);
72+
pinMode(LED_BUILTIN, OUTPUT);
73+
for (;;) digitalWrite(LED_BUILTIN, (millis() * hz / 500) & 1);
74+
}
75+
76+
// Given an [R,G,B] color, apply gamma correction, return packed RGB integer.
77+
uint32_t gammify(uint8_t color[3]) {
78+
uint32_t rgb[3];
79+
for (uint8_t i=0; i<3; i++) {
80+
rgb[i] = uint32_t(pow((float)color[i] / 255.0, GAMMA) * 255 + 0.5);
81+
}
82+
return (rgb[0] << 16) | (rgb[1] << 8) | rgb[2];
83+
}
84+
85+
// Given two [R,G,B] colors and a blend ratio (0.0 to 1.0), interpolate between
86+
// the two colors and return a gamma-corrected in-between color as a packed RGB
87+
// integer. No bounds clamping is performed on blend value, be nice.
88+
uint32_t interp(uint8_t color1[3], uint8_t color2[3], float blend) {
89+
float inv = 1.0 - blend; // Weighting of second color
90+
uint8_t rgb[3];
91+
for(uint8_t i=0; i<3; i++) {
92+
rgb[i] = (int)((float)color1[i] * blend + (float)color2[i] * inv);
93+
}
94+
return gammify(rgb);
95+
}
96+
97+
// Rasterize an arbitrary ellipse into the offscreen 3X canvas, given
98+
// foci point1 and point2 and with area determined by global RADIUS
99+
// (when foci are same point; a circle). Foci and radius are all
100+
// floating point values, which adds to the buttery impression. 'rect'
101+
// is a bounding rect of which pixels are likely affected. Canvas is
102+
// assumed cleared before arriving here.
103+
void rasterize(float point1[2], float point2[2], int rect[4]) {
104+
float perimeter, d;
105+
float dx = point2[0] - point1[0];
106+
float dy = point2[1] - point1[1];
107+
float d2 = dx * dx + dy * dy; // Dist between foci, squared
108+
if (d2 <= 0.0) {
109+
// Foci are in same spot - it's a circle
110+
perimeter = 2.0 * RADIUS;
111+
d = 0.0;
112+
} else {
113+
// Foci are separated - it's an ellipse.
114+
d = sqrt(d2); // Distance between foci
115+
float c = d * 0.5; // Center-to-foci distance
116+
// This is an utterly brute-force way of ellipse-filling based on
117+
// the "two nails and a string" metaphor...we have the foci points
118+
// and just need the string length (triangle perimeter) to yield
119+
// an ellipse with area equal to a circle of 'radius'.
120+
// c^2 = a^2 - b^2 <- ellipse formula
121+
// a = r^2 / b <- substitute
122+
// c^2 = (r^2 / b)^2 - b^2
123+
// b = sqrt(((c^2) + sqrt((c^4) + 4 * r^4)) / 2) <- solve for b
124+
float c2 = c * c;
125+
float b2 = (c2 + sqrt((c2 * c2) + 4 * (RADIUS * RADIUS * RADIUS * RADIUS))) * 0.5;
126+
// By my math, perimeter SHOULD be...
127+
// perimeter = d + 2 * sqrt(b2 + c2);
128+
// ...but for whatever reason, working approach here is really...
129+
perimeter = d + 2 * sqrt(b2);
130+
}
131+
132+
// Like I'm sure there's a way to rasterize this by spans rather than
133+
// all these square roots on every pixel, but for now...
134+
for (int y=rect[1]; y<rect[3]; y++) { // For each row...
135+
float y5 = (float)y + 0.5; // Pixel center
136+
float dy1 = y5 - point1[1]; // Y distance from pixel to first point
137+
float dy2 = y5 - point2[1]; // " to second
138+
dy1 *= dy1; // Y1^2
139+
dy2 *= dy2; // Y2^2
140+
for (int x=rect[0]; x<rect[2]; x++) { // For each column...
141+
float x5 = (float)x + 0.5; // Pixel center
142+
float dx1 = x5 - point1[0]; // X distance from pixel to first point
143+
float dx2 = x5 - point2[0]; // " to second
144+
float d1 = sqrt(dx1 * dx1 + dy1); // 2D distance to first point
145+
float d2 = sqrt(dx2 * dx2 + dy2); // " to second
146+
if ((d1 + d2 + d) <= perimeter) { // Point inside ellipse?
147+
canvas->drawPixel(x, y, eye_color565);
148+
}
149+
}
150+
}
151+
}
152+
153+
154+
// ONE-TIME INITIALIZATION --------------
155+
156+
void setup() {
157+
// Initialize hardware
158+
Serial.begin(115200);
159+
if (! glasses.begin(IS3741_ADDR_DEFAULT, i2c)) err("IS3741 not found", 2);
160+
161+
canvas = glasses.getCanvas();
162+
if (!canvas) err("Can't allocate canvas", 5);
163+
164+
i2c->setClock(1000000); // 1 MHz I2C for extra butteriness
165+
166+
// Configure glasses for reduced brightness, enable output
167+
glasses.setLEDscaling(0xFF);
168+
glasses.setGlobalCurrent(20);
169+
glasses.enable(true);
170+
171+
// INITIALIZE TABLES & OTHER GLOBALS ----
172+
173+
// Pre-compute the Y position of 1/2 of the LEDs in a ring, relative
174+
// to the 3X canvas resolution, so ring & matrix animation can be aligned.
175+
for (uint8_t i=0; i<13; i++) {
176+
float angle = (float)i / 24.0 * M_PI * 2.0;
177+
y_pos[i] = 10.0 - cos(angle) * 12.0;
178+
}
179+
180+
// Convert some colors from [R,G,B] (easier to specify) to packed integers
181+
ring_open_color_packed = gammify(ring_open_color);
182+
eye_color565 = glasses.color565(eye_color[0], eye_color[1], eye_color[2]);
183+
184+
start_time = millis(); // For frames-per-second math
185+
}
186+
187+
// MAIN LOOP ----------------------------
188+
189+
void loop() {
190+
canvas->fillScreen(0);
191+
192+
// The eye animation logic is a carry-over from like a billion
193+
// prior eye projects, so this might be comment-light.
194+
uint32_t now = micros(); // 'Snapshot' the time once per frame
195+
196+
float upper, lower, ratio;
197+
198+
// Blink logic
199+
uint32_t elapsed = now - blink_start_time; // Time since start of blink event
200+
if (elapsed > blink_duration) { // All done with event?
201+
blink_start_time = now; // A new one starts right now
202+
elapsed = 0;
203+
blink_state++; // Cycle closing/opening/paused
204+
if (blink_state == 1) { // Starting new blink...
205+
blink_duration = random(60000, 120000);
206+
} else if (blink_state == 2) { // Switching closing to opening...
207+
blink_duration *= 2; // Opens at half the speed
208+
} else { // Switching to pause in blink
209+
blink_state = 0;
210+
blink_duration = random(500000, 4000000);
211+
}
212+
}
213+
if (blink_state) { // If currently in a blink...
214+
float ratio = (float)elapsed / (float)blink_duration; // 0.0-1.0 as it closes
215+
if (blink_state == 2) ratio = 1.0 - ratio; // 1.0-0.0 as it opens
216+
upper = ratio * 15.0 - 4.0; // Upper eyelid pos. in 3X space
217+
lower = 23.0 - ratio * 8.0; // Lower eyelid pos. in 3X space
218+
}
219+
220+
// Eye movement logic. Two points, 'p1' and 'p2', are the foci of an
221+
// ellipse. p1 moves from current to next position a little faster
222+
// than p2, creating a "squash and stretch" effect (frame rate and
223+
// resolution permitting). When motion is stopped, the two points
224+
// are at the same position.
225+
float p1[2], p2[2];
226+
elapsed = now - move_start_time; // Time since start of move event
227+
if (in_motion) { // Currently moving?
228+
if (elapsed > move_duration) { // If end of motion reached,
229+
in_motion = false; // Stop motion and
230+
memcpy(&p1, &next_pos, sizeof next_pos); // set everything to new position
231+
memcpy(&p2, &next_pos, sizeof next_pos);
232+
memcpy(&cur_pos, &next_pos, sizeof next_pos);
233+
move_duration = random(500000, 1500000); // Wait this long
234+
} else { // Still moving
235+
// Determine p1, p2 position in time
236+
float delta[2];
237+
delta[0] = next_pos[0] - cur_pos[0];
238+
delta[1] = next_pos[1] - cur_pos[1];
239+
ratio = (float)elapsed / (float)move_duration;
240+
if (ratio < 0.6) { // First 60% of move time, p1 is in motion
241+
// Easing function: 3*e^2-2*e^3 0.0 to 1.0
242+
float e = ratio / 0.6; // 0.0 to 1.0
243+
e = 3 * e * e - 2 * e * e * e;
244+
p1[0] = cur_pos[0] + delta[0] * e;
245+
p1[1] = cur_pos[1] + delta[1] * e;
246+
} else { // Last 40% of move time
247+
memcpy(&p1, &next_pos, sizeof next_pos); // p1 has reached end position
248+
}
249+
if (ratio > 0.3) { // Last 70% of move time, p2 is in motion
250+
float e = (ratio - 0.3) / 0.7; // 0.0 to 1.0
251+
e = 3 * e * e - 2 * e * e * e; // Easing func.
252+
p2[0] = cur_pos[0] + delta[0] * e;
253+
p2[1] = cur_pos[1] + delta[1] * e;
254+
} else { // First 30% of move time
255+
memcpy(&p2, &cur_pos, sizeof cur_pos); // p2 waits at start position
256+
}
257+
}
258+
} else { // Eye is stopped
259+
memcpy(&p1, &cur_pos, sizeof cur_pos); // Both foci at current eye position
260+
memcpy(&p2, &cur_pos, sizeof cur_pos);
261+
if (elapsed > move_duration) { // Pause time expired?
262+
in_motion = true; // Start up new motion!
263+
move_start_time = now;
264+
move_duration = random(150000, 250000);
265+
float angle = (float)random(1000) / 1000.0 * M_PI * 2.0;
266+
float dist = (float)random(750) / 100.0;
267+
next_pos[0] = 9.0 + cos(angle) * dist;
268+
next_pos[1] = 7.5 + sin(angle) * dist * 0.8;
269+
}
270+
}
271+
272+
// Draw the raster part of each eye...
273+
for (uint8_t e=0; e<2; e++) {
274+
// Each eye's foci are offset slightly, to fixate toward center
275+
float p1a[2], p2a[2];
276+
p1a[0] = p1[0] + x_offset[e];
277+
p2a[0] = p2[0] + x_offset[e];
278+
p1a[1] = p2a[1] = p1[1];
279+
// Compute bounding rectangle (in 3X space) of ellipse
280+
// (min X, min Y, max X, max Y). Like the ellipse rasterizer,
281+
// this isn't optimal, but will suffice.
282+
int bounds[4];
283+
bounds[0] = max(int(min(p1a[0], p2a[0]) - RADIUS), box_x_min[e]);
284+
bounds[1] = max(max(int(min(p1a[1], p2a[1]) - RADIUS), 0), (int)upper);
285+
bounds[2] = min(int(max(p1a[0], p2a[0]) + RADIUS + 1), box_x_max[e]);
286+
bounds[3] = min(int(max(p1a[1], p2a[1]) + RADIUS + 1), 15);
287+
rasterize(p1a, p2a, bounds); // Render ellipse into buffer
288+
}
289+
290+
// If the eye is currently blinking, and if the top edge of the eyelid
291+
// overlaps the bitmap, draw lines across the bitmap as if eyelids.
292+
if (blink_state and upper >= 0.0) {
293+
int iu = (int)upper;
294+
canvas->drawLine(box_x_min[0], iu, box_x_max[0] - 1, iu, eye_color565);
295+
canvas->drawLine(box_x_min[1], iu, box_x_max[1] - 1, iu, eye_color565);
296+
}
297+
298+
glasses.scale(); // Smooth filter 3X canvas to LED grid
299+
300+
// Matrix and rings share a few pixels. To make the rings take
301+
// precedence, they're drawn later. So blink state is revisited now...
302+
if (blink_state) { // In mid-blink?
303+
for (uint8_t i=0; i<13; i++) { // Half an LED ring, top-to-bottom...
304+
float a = min(max(y_pos[i] - upper + 1.0, 0.0), 3.0);
305+
float b = min(max(lower - y_pos[i] + 1.0, 0.0), 3.0);
306+
ratio = a * b / 9.0; // Proximity of LED to eyelid edges
307+
uint32_t packed = interp(ring_open_color, ring_blink_color, ratio);
308+
glasses.left_ring.setPixelColor(i, packed);
309+
glasses.right_ring.setPixelColor(i, packed);
310+
if ((i > 0) && (i < 12)) {
311+
uint8_t j = 24 - i; // Mirror half-ring to other side
312+
glasses.left_ring.setPixelColor(j, packed);
313+
glasses.right_ring.setPixelColor(j, packed);
314+
}
315+
}
316+
} else {
317+
glasses.left_ring.fill(ring_open_color_packed);
318+
glasses.right_ring.fill(ring_open_color_packed);
319+
}
320+
321+
glasses.show();
322+
323+
frames += 1;
324+
elapsed = millis() - start_time;
325+
Serial.println(frames * 1000 / elapsed);
326+
}

0 commit comments

Comments
 (0)