Merge pull request #1895 from PaintYourDragon/main
Add EyeLights blinky eyes, both CircuitPython and Arduino
This commit is contained in:
commit
8b2e3c88f4
3 changed files with 664 additions and 0 deletions
|
|
@ -0,0 +1,326 @@
|
|||
// SPDX-FileCopyrightText: 2021 Phil Burgess for Adafruit Industries
|
||||
//
|
||||
// SPDX-License-Identifier: MIT
|
||||
|
||||
/*
|
||||
MOVE-AND-BLINK EYES for Adafruit EyeLights (LED Glasses + Driver).
|
||||
|
||||
I'd written a very cool squash-and-stretch effect for the eye movement,
|
||||
but unfortunately the resolution is such that the pupils just look like
|
||||
circles regardless. I'm keeping it in despite the added complexity,
|
||||
because this WILL look great later on a bigger matrix or a TFT/OLED,
|
||||
and this way the hard parts won't require a re-write at such time.
|
||||
It's a really adorable effect with enough pixels.
|
||||
*/
|
||||
|
||||
#include <Adafruit_IS31FL3741.h> // For LED driver
|
||||
|
||||
// CONFIGURABLES ------------------------
|
||||
|
||||
#define RADIUS 3.4 // Size of pupil (3X because of downsampling later)
|
||||
|
||||
uint8_t eye_color[3] = { 255, 128, 0 }; // Amber pupils
|
||||
uint8_t ring_open_color[3] = { 75, 75, 75 }; // Color of LED rings when eyes open
|
||||
uint8_t ring_blink_color[3] = { 50, 25, 0 }; // Color of LED ring "eyelid" when blinking
|
||||
|
||||
// Some boards have just one I2C interface, but some have more...
|
||||
TwoWire *i2c = &Wire; // e.g. change this to &Wire1 for QT Py RP2040
|
||||
|
||||
// GLOBAL VARIABLES ---------------------
|
||||
|
||||
Adafruit_EyeLights_buffered glasses(true); // Buffered spex + 3X canvas
|
||||
GFXcanvas16 *canvas; // Pointer to canvas object
|
||||
|
||||
// Reading through the code, you'll see a lot of references to this "3X"
|
||||
// space. This is referring to the glasses' optional "offscreen" drawing
|
||||
// canvas that's 3 times the resolution of the LED matrix (i.e. 15 pixels
|
||||
// tall instead of 5), which gets scaled down to provide some degree of
|
||||
// antialiasing. It's why the pupils have soft edges and can make
|
||||
// fractional-pixel motions.
|
||||
|
||||
float cur_pos[2] = { 9.0, 7.5 }; // Current position of eye in canvas space
|
||||
float next_pos[2] = { 9.0, 7.5 }; // Next position "
|
||||
bool in_motion = false; // true = eyes moving, false = eyes paused
|
||||
uint8_t blink_state = 0; // 0, 1, 2 = unblinking, closing, opening
|
||||
uint32_t move_start_time = 0; // For animation timekeeping
|
||||
uint32_t move_duration = 0;
|
||||
uint32_t blink_start_time = 0;
|
||||
uint32_t blink_duration = 0;
|
||||
float y_pos[13]; // Coords of LED ring pixels in canvas space
|
||||
uint32_t ring_open_color_packed; // ring_open_color[] as packed RGB integer
|
||||
uint16_t eye_color565; // eye_color[] as a GFX packed '565' value
|
||||
uint32_t frames = 0; // For frames-per-second calculation
|
||||
uint32_t start_time;
|
||||
|
||||
// These offsets position each pupil on the canvas grid and make them
|
||||
// fixate slightly (converge on a point) so they're not always aligned
|
||||
// the same on the pixel grid, which would be conspicuously pixel-y.
|
||||
float x_offset[2] = { 5.0, 31.0 };
|
||||
// These help perform x-axis clipping on the rasterized ellipses,
|
||||
// so they don't "bleed" outside the rings and require erasing.
|
||||
int box_x_min[2] = { 3, 33 };
|
||||
int box_x_max[2] = { 21, 51 };
|
||||
|
||||
#define GAMMA 2.6 // For color correction, shouldn't need changing
|
||||
|
||||
|
||||
// HELPER FUNCTIONS ---------------------
|
||||
|
||||
// Crude error handler, prints message to Serial console, flashes LED
|
||||
void err(char *str, uint8_t hz) {
|
||||
Serial.println(str);
|
||||
pinMode(LED_BUILTIN, OUTPUT);
|
||||
for (;;) digitalWrite(LED_BUILTIN, (millis() * hz / 500) & 1);
|
||||
}
|
||||
|
||||
// Given an [R,G,B] color, apply gamma correction, return packed RGB integer.
|
||||
uint32_t gammify(uint8_t color[3]) {
|
||||
uint32_t rgb[3];
|
||||
for (uint8_t i=0; i<3; i++) {
|
||||
rgb[i] = uint32_t(pow((float)color[i] / 255.0, GAMMA) * 255 + 0.5);
|
||||
}
|
||||
return (rgb[0] << 16) | (rgb[1] << 8) | rgb[2];
|
||||
}
|
||||
|
||||
// Given two [R,G,B] colors and a blend ratio (0.0 to 1.0), interpolate between
|
||||
// the two colors and return a gamma-corrected in-between color as a packed RGB
|
||||
// integer. No bounds clamping is performed on blend value, be nice.
|
||||
uint32_t interp(uint8_t color1[3], uint8_t color2[3], float blend) {
|
||||
float inv = 1.0 - blend; // Weighting of second color
|
||||
uint8_t rgb[3];
|
||||
for(uint8_t i=0; i<3; i++) {
|
||||
rgb[i] = (int)((float)color1[i] * blend + (float)color2[i] * inv);
|
||||
}
|
||||
return gammify(rgb);
|
||||
}
|
||||
|
||||
// Rasterize an arbitrary ellipse into the offscreen 3X canvas, given
|
||||
// foci point1 and point2 and with area determined by global RADIUS
|
||||
// (when foci are same point; a circle). Foci and radius are all
|
||||
// floating point values, which adds to the buttery impression. 'rect'
|
||||
// is a bounding rect of which pixels are likely affected. Canvas is
|
||||
// assumed cleared before arriving here.
|
||||
void rasterize(float point1[2], float point2[2], int rect[4]) {
|
||||
float perimeter, d;
|
||||
float dx = point2[0] - point1[0];
|
||||
float dy = point2[1] - point1[1];
|
||||
float d2 = dx * dx + dy * dy; // Dist between foci, squared
|
||||
if (d2 <= 0.0) {
|
||||
// Foci are in same spot - it's a circle
|
||||
perimeter = 2.0 * RADIUS;
|
||||
d = 0.0;
|
||||
} else {
|
||||
// Foci are separated - it's an ellipse.
|
||||
d = sqrt(d2); // Distance between foci
|
||||
float c = d * 0.5; // Center-to-foci distance
|
||||
// This is an utterly brute-force way of ellipse-filling based on
|
||||
// the "two nails and a string" metaphor...we have the foci points
|
||||
// and just need the string length (triangle perimeter) to yield
|
||||
// an ellipse with area equal to a circle of 'radius'.
|
||||
// c^2 = a^2 - b^2 <- ellipse formula
|
||||
// a = r^2 / b <- substitute
|
||||
// c^2 = (r^2 / b)^2 - b^2
|
||||
// b = sqrt(((c^2) + sqrt((c^4) + 4 * r^4)) / 2) <- solve for b
|
||||
float c2 = c * c;
|
||||
float b2 = (c2 + sqrt((c2 * c2) + 4 * (RADIUS * RADIUS * RADIUS * RADIUS))) * 0.5;
|
||||
// By my math, perimeter SHOULD be...
|
||||
// perimeter = d + 2 * sqrt(b2 + c2);
|
||||
// ...but for whatever reason, working approach here is really...
|
||||
perimeter = d + 2 * sqrt(b2);
|
||||
}
|
||||
|
||||
// Like I'm sure there's a way to rasterize this by spans rather than
|
||||
// all these square roots on every pixel, but for now...
|
||||
for (int y=rect[1]; y<rect[3]; y++) { // For each row...
|
||||
float y5 = (float)y + 0.5; // Pixel center
|
||||
float dy1 = y5 - point1[1]; // Y distance from pixel to first point
|
||||
float dy2 = y5 - point2[1]; // " to second
|
||||
dy1 *= dy1; // Y1^2
|
||||
dy2 *= dy2; // Y2^2
|
||||
for (int x=rect[0]; x<rect[2]; x++) { // For each column...
|
||||
float x5 = (float)x + 0.5; // Pixel center
|
||||
float dx1 = x5 - point1[0]; // X distance from pixel to first point
|
||||
float dx2 = x5 - point2[0]; // " to second
|
||||
float d1 = sqrt(dx1 * dx1 + dy1); // 2D distance to first point
|
||||
float d2 = sqrt(dx2 * dx2 + dy2); // " to second
|
||||
if ((d1 + d2 + d) <= perimeter) { // Point inside ellipse?
|
||||
canvas->drawPixel(x, y, eye_color565);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ONE-TIME INITIALIZATION --------------
|
||||
|
||||
void setup() {
|
||||
// Initialize hardware
|
||||
Serial.begin(115200);
|
||||
if (! glasses.begin(IS3741_ADDR_DEFAULT, i2c)) err("IS3741 not found", 2);
|
||||
|
||||
canvas = glasses.getCanvas();
|
||||
if (!canvas) err("Can't allocate canvas", 5);
|
||||
|
||||
i2c->setClock(1000000); // 1 MHz I2C for extra butteriness
|
||||
|
||||
// Configure glasses for reduced brightness, enable output
|
||||
glasses.setLEDscaling(0xFF);
|
||||
glasses.setGlobalCurrent(20);
|
||||
glasses.enable(true);
|
||||
|
||||
// INITIALIZE TABLES & OTHER GLOBALS ----
|
||||
|
||||
// Pre-compute the Y position of 1/2 of the LEDs in a ring, relative
|
||||
// to the 3X canvas resolution, so ring & matrix animation can be aligned.
|
||||
for (uint8_t i=0; i<13; i++) {
|
||||
float angle = (float)i / 24.0 * M_PI * 2.0;
|
||||
y_pos[i] = 10.0 - cos(angle) * 12.0;
|
||||
}
|
||||
|
||||
// Convert some colors from [R,G,B] (easier to specify) to packed integers
|
||||
ring_open_color_packed = gammify(ring_open_color);
|
||||
eye_color565 = glasses.color565(eye_color[0], eye_color[1], eye_color[2]);
|
||||
|
||||
start_time = millis(); // For frames-per-second math
|
||||
}
|
||||
|
||||
// MAIN LOOP ----------------------------
|
||||
|
||||
void loop() {
|
||||
canvas->fillScreen(0);
|
||||
|
||||
// The eye animation logic is a carry-over from like a billion
|
||||
// prior eye projects, so this might be comment-light.
|
||||
uint32_t now = micros(); // 'Snapshot' the time once per frame
|
||||
|
||||
float upper, lower, ratio;
|
||||
|
||||
// Blink logic
|
||||
uint32_t elapsed = now - blink_start_time; // Time since start of blink event
|
||||
if (elapsed > blink_duration) { // All done with event?
|
||||
blink_start_time = now; // A new one starts right now
|
||||
elapsed = 0;
|
||||
blink_state++; // Cycle closing/opening/paused
|
||||
if (blink_state == 1) { // Starting new blink...
|
||||
blink_duration = random(60000, 120000);
|
||||
} else if (blink_state == 2) { // Switching closing to opening...
|
||||
blink_duration *= 2; // Opens at half the speed
|
||||
} else { // Switching to pause in blink
|
||||
blink_state = 0;
|
||||
blink_duration = random(500000, 4000000);
|
||||
}
|
||||
}
|
||||
if (blink_state) { // If currently in a blink...
|
||||
float ratio = (float)elapsed / (float)blink_duration; // 0.0-1.0 as it closes
|
||||
if (blink_state == 2) ratio = 1.0 - ratio; // 1.0-0.0 as it opens
|
||||
upper = ratio * 15.0 - 4.0; // Upper eyelid pos. in 3X space
|
||||
lower = 23.0 - ratio * 8.0; // Lower eyelid pos. in 3X space
|
||||
}
|
||||
|
||||
// Eye movement logic. Two points, 'p1' and 'p2', are the foci of an
|
||||
// ellipse. p1 moves from current to next position a little faster
|
||||
// than p2, creating a "squash and stretch" effect (frame rate and
|
||||
// resolution permitting). When motion is stopped, the two points
|
||||
// are at the same position.
|
||||
float p1[2], p2[2];
|
||||
elapsed = now - move_start_time; // Time since start of move event
|
||||
if (in_motion) { // Currently moving?
|
||||
if (elapsed > move_duration) { // If end of motion reached,
|
||||
in_motion = false; // Stop motion and
|
||||
memcpy(&p1, &next_pos, sizeof next_pos); // set everything to new position
|
||||
memcpy(&p2, &next_pos, sizeof next_pos);
|
||||
memcpy(&cur_pos, &next_pos, sizeof next_pos);
|
||||
move_duration = random(500000, 1500000); // Wait this long
|
||||
} else { // Still moving
|
||||
// Determine p1, p2 position in time
|
||||
float delta[2];
|
||||
delta[0] = next_pos[0] - cur_pos[0];
|
||||
delta[1] = next_pos[1] - cur_pos[1];
|
||||
ratio = (float)elapsed / (float)move_duration;
|
||||
if (ratio < 0.6) { // First 60% of move time, p1 is in motion
|
||||
// Easing function: 3*e^2-2*e^3 0.0 to 1.0
|
||||
float e = ratio / 0.6; // 0.0 to 1.0
|
||||
e = 3 * e * e - 2 * e * e * e;
|
||||
p1[0] = cur_pos[0] + delta[0] * e;
|
||||
p1[1] = cur_pos[1] + delta[1] * e;
|
||||
} else { // Last 40% of move time
|
||||
memcpy(&p1, &next_pos, sizeof next_pos); // p1 has reached end position
|
||||
}
|
||||
if (ratio > 0.3) { // Last 70% of move time, p2 is in motion
|
||||
float e = (ratio - 0.3) / 0.7; // 0.0 to 1.0
|
||||
e = 3 * e * e - 2 * e * e * e; // Easing func.
|
||||
p2[0] = cur_pos[0] + delta[0] * e;
|
||||
p2[1] = cur_pos[1] + delta[1] * e;
|
||||
} else { // First 30% of move time
|
||||
memcpy(&p2, &cur_pos, sizeof cur_pos); // p2 waits at start position
|
||||
}
|
||||
}
|
||||
} else { // Eye is stopped
|
||||
memcpy(&p1, &cur_pos, sizeof cur_pos); // Both foci at current eye position
|
||||
memcpy(&p2, &cur_pos, sizeof cur_pos);
|
||||
if (elapsed > move_duration) { // Pause time expired?
|
||||
in_motion = true; // Start up new motion!
|
||||
move_start_time = now;
|
||||
move_duration = random(150000, 250000);
|
||||
float angle = (float)random(1000) / 1000.0 * M_PI * 2.0;
|
||||
float dist = (float)random(750) / 100.0;
|
||||
next_pos[0] = 9.0 + cos(angle) * dist;
|
||||
next_pos[1] = 7.5 + sin(angle) * dist * 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
// Draw the raster part of each eye...
|
||||
for (uint8_t e=0; e<2; e++) {
|
||||
// Each eye's foci are offset slightly, to fixate toward center
|
||||
float p1a[2], p2a[2];
|
||||
p1a[0] = p1[0] + x_offset[e];
|
||||
p2a[0] = p2[0] + x_offset[e];
|
||||
p1a[1] = p2a[1] = p1[1];
|
||||
// Compute bounding rectangle (in 3X space) of ellipse
|
||||
// (min X, min Y, max X, max Y). Like the ellipse rasterizer,
|
||||
// this isn't optimal, but will suffice.
|
||||
int bounds[4];
|
||||
bounds[0] = max(int(min(p1a[0], p2a[0]) - RADIUS), box_x_min[e]);
|
||||
bounds[1] = max(max(int(min(p1a[1], p2a[1]) - RADIUS), 0), (int)upper);
|
||||
bounds[2] = min(int(max(p1a[0], p2a[0]) + RADIUS + 1), box_x_max[e]);
|
||||
bounds[3] = min(int(max(p1a[1], p2a[1]) + RADIUS + 1), 15);
|
||||
rasterize(p1a, p2a, bounds); // Render ellipse into buffer
|
||||
}
|
||||
|
||||
// If the eye is currently blinking, and if the top edge of the eyelid
|
||||
// overlaps the bitmap, draw lines across the bitmap as if eyelids.
|
||||
if (blink_state and upper >= 0.0) {
|
||||
int iu = (int)upper;
|
||||
canvas->drawLine(box_x_min[0], iu, box_x_max[0] - 1, iu, eye_color565);
|
||||
canvas->drawLine(box_x_min[1], iu, box_x_max[1] - 1, iu, eye_color565);
|
||||
}
|
||||
|
||||
glasses.scale(); // Smooth filter 3X canvas to LED grid
|
||||
|
||||
// Matrix and rings share a few pixels. To make the rings take
|
||||
// precedence, they're drawn later. So blink state is revisited now...
|
||||
if (blink_state) { // In mid-blink?
|
||||
for (uint8_t i=0; i<13; i++) { // Half an LED ring, top-to-bottom...
|
||||
float a = min(max(y_pos[i] - upper + 1.0, 0.0), 3.0);
|
||||
float b = min(max(lower - y_pos[i] + 1.0, 0.0), 3.0);
|
||||
ratio = a * b / 9.0; // Proximity of LED to eyelid edges
|
||||
uint32_t packed = interp(ring_open_color, ring_blink_color, ratio);
|
||||
glasses.left_ring.setPixelColor(i, packed);
|
||||
glasses.right_ring.setPixelColor(i, packed);
|
||||
if ((i > 0) && (i < 12)) {
|
||||
uint8_t j = 24 - i; // Mirror half-ring to other side
|
||||
glasses.left_ring.setPixelColor(j, packed);
|
||||
glasses.right_ring.setPixelColor(j, packed);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
glasses.left_ring.fill(ring_open_color_packed);
|
||||
glasses.right_ring.fill(ring_open_color_packed);
|
||||
}
|
||||
|
||||
glasses.show();
|
||||
|
||||
frames += 1;
|
||||
elapsed = millis() - start_time;
|
||||
Serial.println(frames * 1000 / elapsed);
|
||||
}
|
||||
338
EyeLights_Blinky_Eyes/EyeLights_Blinky_Eyes_CircuitPython/code.py
Executable file
338
EyeLights_Blinky_Eyes/EyeLights_Blinky_Eyes_CircuitPython/code.py
Executable file
|
|
@ -0,0 +1,338 @@
|
|||
# SPDX-FileCopyrightText: 2021 Phil Burgess for Adafruit Industries
|
||||
#
|
||||
# SPDX-License-Identifier: MIT
|
||||
|
||||
"""
|
||||
MOVE-AND-BLINK EYES for Adafruit EyeLights (LED Glasses + Driver).
|
||||
|
||||
I'd written a very cool squash-and-stretch effect for the eye movement,
|
||||
but unfortunately the resolution and frame rate are such that the pupils
|
||||
just look like circles regardless. I'm keeping it in despite the added
|
||||
complexity, because CircuitPython devices WILL get faster, LED matrix
|
||||
densities WILL improve, and this way the code won't require a re-write
|
||||
at such a later time. It's a really adorable effect with enough pixels.
|
||||
"""
|
||||
|
||||
import math
|
||||
import random
|
||||
import time
|
||||
from supervisor import reload
|
||||
import board
|
||||
from busio import I2C
|
||||
import adafruit_is31fl3741
|
||||
from adafruit_is31fl3741.adafruit_ledglasses import LED_Glasses
|
||||
|
||||
|
||||
# CONFIGURABLES ------------------------
|
||||
|
||||
eye_color = (255, 128, 0) # Amber pupils
|
||||
ring_open_color = (75, 75, 75) # Color of LED rings when eyes open
|
||||
ring_blink_color = (50, 25, 0) # Color of LED ring "eyelid" when blinking
|
||||
|
||||
radius = 3.4 # Size of pupil (3X because of downsampling later)
|
||||
|
||||
# Reading through the code, you'll see a lot of references to this "3X"
|
||||
# space. What it's referring to is a bitmap that's 3 times the resolution
|
||||
# of the LED matrix (i.e. 15 pixels tall instead of 5), which gets scaled
|
||||
# down to provide some degree of antialiasing. It's why the pupils have
|
||||
# soft edges and can make fractional-pixel motions.
|
||||
# Because of the way the downsampling is done, the eyelid edge when drawn
|
||||
# across the eye will always be the same hue as the pupils, it can't be
|
||||
# set independently like the ring blink color.
|
||||
|
||||
gamma = 2.6 # For color adjustment. Leave as-is.
|
||||
|
||||
|
||||
# CLASSES & FUNCTIONS ------------------
|
||||
|
||||
|
||||
class Eye:
|
||||
"""Holds per-eye positional data; each covers a different area of the
|
||||
overall LED matrix."""
|
||||
|
||||
def __init__(self, left, xoff):
|
||||
self.left = left # Leftmost column on LED matrix
|
||||
self.x_offset = xoff # Horizontal offset (3X space) to fixate
|
||||
|
||||
def smooth(self, data, rect):
|
||||
"""Scale bitmap (in 'data') to LED array, with smooth 1:3
|
||||
downsampling. 'rect' is a 4-tuple rect of which pixels get
|
||||
filtered (anything outside is cleared to 0), saves a few cycles."""
|
||||
# Quantize bounds rect from 3X space to LED matrix space.
|
||||
rect = (
|
||||
rect[0] // 3, # Left
|
||||
rect[1] // 3, # Top
|
||||
(rect[2] + 2) // 3, # Right
|
||||
(rect[3] + 2) // 3, # Bottom
|
||||
)
|
||||
for y in range(rect[1]): # Erase rows above top
|
||||
for x in range(6):
|
||||
glasses.pixel(self.left + x, y, 0)
|
||||
for y in range(rect[1], rect[3]): # Each row, top to bottom...
|
||||
pixel_sum = bytearray(6) # Initialize row of pixel sums to 0
|
||||
for y1 in range(3): # 3 rows of bitmap...
|
||||
row = data[y * 3 + y1] # Bitmap data for current row
|
||||
for x in range(rect[0], rect[2]): # Column, left to right
|
||||
x3 = x * 3
|
||||
# Accumulate 3 pixels of bitmap into pixel_sum
|
||||
pixel_sum[x] += row[x3] + row[x3 + 1] + row[x3 + 2]
|
||||
# 'pixel_sum' will now contain values from 0-9, indicating the
|
||||
# number of set pixels in the corresponding section of the 3X
|
||||
# bitmap. 'colormap' expands the sum to 24-bit RGB space.
|
||||
for x in range(rect[0]): # Erase any columns to left
|
||||
glasses.pixel(self.left + x, y, 0)
|
||||
for x in range(rect[0], rect[2]): # Column, left to right
|
||||
glasses.pixel(self.left + x, y, colormap[pixel_sum[x]])
|
||||
for x in range(rect[2], 6): # Erase columns to right
|
||||
glasses.pixel(self.left + x, y, 0)
|
||||
for y in range(rect[3], 5): # Erase rows below bottom
|
||||
for x in range(6):
|
||||
glasses.pixel(self.left + x, y, 0)
|
||||
|
||||
|
||||
# pylint: disable=too-many-locals
|
||||
def rasterize(data, point1, point2, rect):
|
||||
"""Rasterize an arbitrary ellipse into the 'data' bitmap (3X pixel
|
||||
space), given foci point1 and point2 and with area determined by global
|
||||
'radius' (when foci are same point; a circle). Foci and radius are all
|
||||
floating point values, which adds to the buttery impression. 'rect' is
|
||||
a 4-tuple rect of which pixels are likely affected. Data is assumed 0
|
||||
before arriving here; no clearing is performed."""
|
||||
|
||||
dx = point2[0] - point1[0]
|
||||
dy = point2[1] - point1[1]
|
||||
d2 = dx * dx + dy * dy # Dist between foci, squared
|
||||
if d2 <= 0:
|
||||
# Foci are in same spot - it's a circle
|
||||
perimeter = 2 * radius
|
||||
d = 0
|
||||
else:
|
||||
# Foci are separated - it's an ellipse.
|
||||
d = d2 ** 0.5 # Distance between foci
|
||||
c = d * 0.5 # Center-to-foci distance
|
||||
# This is an utterly brute-force way of ellipse-filling based on
|
||||
# the "two nails and a string" metaphor...we have the foci points
|
||||
# and just need the string length (triangle perimeter) to yield
|
||||
# an ellipse with area equal to a circle of 'radius'.
|
||||
# c^2 = a^2 - b^2 <- ellipse formula
|
||||
# a = r^2 / b <- substitute
|
||||
# c^2 = (r^2 / b)^2 - b^2
|
||||
# b = sqrt(((c^2) + sqrt((c^4) + 4 * r^4)) / 2) <- solve for b
|
||||
b2 = ((c ** 2) + (((c ** 4) + 4 * (radius ** 4)) ** 0.5)) * 0.5
|
||||
# By my math, perimeter SHOULD be...
|
||||
# perimeter = d + 2 * ((b2 + (c ** 2)) ** 0.5)
|
||||
# ...but for whatever reason, working approach here is really...
|
||||
perimeter = d + 2 * (b2 ** 0.5)
|
||||
|
||||
# Like I'm sure there's a way to rasterize this by spans rather than
|
||||
# all these square roots on every pixel, but for now...
|
||||
for y in range(rect[1], rect[3]): # For each row...
|
||||
y5 = y + 0.5 # Pixel center
|
||||
dy1 = y5 - point1[1] # Y distance from pixel to first point
|
||||
dy2 = y5 - point2[1] # " to second
|
||||
dy1 *= dy1 # Y1^2
|
||||
dy2 *= dy2 # Y2^2
|
||||
for x in range(rect[0], rect[2]): # For each column...
|
||||
x5 = x + 0.5 # Pixel center
|
||||
dx1 = x5 - point1[0] # X distance from pixel to first point
|
||||
dx2 = x5 - point2[0] # " to second
|
||||
d1 = (dx1 * dx1 + dy1) ** 0.5 # 2D distance to first point
|
||||
d2 = (dx2 * dx2 + dy2) ** 0.5 # " to second
|
||||
if (d1 + d2 + d) <= perimeter:
|
||||
data[y][x] = 1 # Point is inside ellipse
|
||||
|
||||
|
||||
def gammify(color):
|
||||
"""Given an (R,G,B) color tuple, apply gamma correction and return
|
||||
a packed 24-bit RGB integer."""
|
||||
rgb = [int(((color[x] / 255) ** gamma) * 255 + 0.5) for x in range(3)]
|
||||
return (rgb[0] << 16) | (rgb[1] << 8) | rgb[2]
|
||||
|
||||
|
||||
def interp(color1, color2, blend):
|
||||
"""Given two (R,G,B) color tuples and a blend ratio (0.0 to 1.0),
|
||||
interpolate between the two colors and return a gamma-corrected
|
||||
in-between color as a packed 24-bit RGB integer. No bounds clamping
|
||||
is performed on blend value, be nice."""
|
||||
inv = 1.0 - blend # Weighting of second color
|
||||
return gammify([color1[x] * blend + color2[x] * inv for x in range(3)])
|
||||
|
||||
|
||||
# HARDWARE SETUP -----------------------
|
||||
|
||||
# Manually declare I2C (not board.I2C() directly) to access 1 MHz speed...
|
||||
i2c = I2C(board.SCL, board.SDA, frequency=1000000)
|
||||
|
||||
# Initialize the IS31 LED driver, buffered for smoother animation
|
||||
glasses = LED_Glasses(i2c, allocate=adafruit_is31fl3741.MUST_BUFFER)
|
||||
glasses.show() # Clear any residue on startup
|
||||
glasses.global_current = 20 # Just middlin' bright, please
|
||||
|
||||
|
||||
# INITIALIZE TABLES & OTHER GLOBALS ----
|
||||
|
||||
# This table is for mapping 3x3 averaged bitmap values (0-9) to
|
||||
# RGB colors. Avoids a lot of shift-and-or on every pixel.
|
||||
colormap = []
|
||||
for n in range(10):
|
||||
colormap.append(gammify([n / 9 * eye_color[x] for x in range(3)]))
|
||||
|
||||
# Pre-compute the Y position of 1/2 of the LEDs in a ring, relative
|
||||
# to the 3X bitmap resolution, so ring & matrix animation can be aligned.
|
||||
y_pos = []
|
||||
for n in range(13):
|
||||
angle = n / 24 * math.pi * 2
|
||||
y_pos.append(10 - math.cos(angle) * 12)
|
||||
|
||||
# Pre-compute color of LED ring in fully open (unblinking) state
|
||||
ring_open_color_packed = gammify(ring_open_color)
|
||||
|
||||
# A single pre-computed scanline of "eyelid edge during blink" can be
|
||||
# stuffed into the 3X raster as needed, avoids setting pixels manually.
|
||||
eyelid = (
|
||||
b"\x01\x01\x00\x01\x01\x00\x01\x01\x00" b"\x01\x01\x00\x01\x01\x00\x01\x01\x00"
|
||||
) # 2/3 of pixels set
|
||||
|
||||
# Initialize eye position and move/blink animation timekeeping
|
||||
cur_pos = next_pos = (9, 7.5) # Current, next eye position in 3X space
|
||||
in_motion = False # True = eyes moving, False = eyes paused
|
||||
blink_state = 0 # 0, 1, 2 = unblinking, closing, opening
|
||||
move_start_time = move_duration = blink_start_time = blink_duration = 0
|
||||
|
||||
# Two eye objects. The first starts at column 1 of the matrix with its
|
||||
# pupil offset by +2 (in 3X space), second at column 11 with -2 offset.
|
||||
# The offsets make the pupils fixate slightly (converge on a point), so
|
||||
# the two pupils aren't always aligned the same on the pixel grid, which
|
||||
# would be conspicuously pixel-y.
|
||||
eyes = [Eye(1, 2), Eye(11, -2)]
|
||||
|
||||
frames, start_time = 0, time.monotonic() # For frames/second calculation
|
||||
|
||||
|
||||
# MAIN LOOP ----------------------------
|
||||
|
||||
while True:
|
||||
# The try/except here is because VERY INFREQUENTLY the I2C bus will
|
||||
# encounter an error when accessing the LED driver, whether from bumping
|
||||
# around the wires or sometimes an I2C device just gets wedged. To more
|
||||
# robustly handle the latter, the code will restart if that happens.
|
||||
try:
|
||||
|
||||
# The eye animation logic is a carry-over from like a billion
|
||||
# prior eye projects, so this might be comment-light.
|
||||
now = time.monotonic() # 'Snapshot' the time once per frame
|
||||
|
||||
# Blink logic
|
||||
elapsed = now - blink_start_time # Time since start of blink event
|
||||
if elapsed > blink_duration: # All done with event?
|
||||
blink_start_time = now # A new one starts right now
|
||||
elapsed = 0
|
||||
blink_state += 1 # Cycle closing/opening/paused
|
||||
if blink_state == 1: # Starting new blink...
|
||||
blink_duration = random.uniform(0.06, 0.12)
|
||||
elif blink_state == 2: # Switching closing to opening...
|
||||
blink_duration *= 2 # Opens at half the speed
|
||||
else: # Switching to pause in blink
|
||||
blink_state = 0
|
||||
blink_duration = random.uniform(0.5, 4)
|
||||
if blink_state: # If currently in a blink...
|
||||
ratio = elapsed / blink_duration # 0.0-1.0 as it closes
|
||||
if blink_state == 2:
|
||||
ratio = 1.0 - ratio # 1.0-0.0 as it opens
|
||||
upper = ratio * 15 - 4 # Upper eyelid pos. in 3X space
|
||||
lower = 23 - ratio * 8 # Lower eyelid pos. in 3X space
|
||||
|
||||
# Eye movement logic. Two points, 'p1' and 'p2', are the foci of an
|
||||
# ellipse. p1 moves from current to next position a little faster
|
||||
# than p2, creating a "squash and stretch" effect (frame rate and
|
||||
# resolution permitting). When motion is stopped, the two points
|
||||
# are at the same position.
|
||||
elapsed = now - move_start_time # Time since start of move event
|
||||
if in_motion: # Currently moving?
|
||||
if elapsed > move_duration: # If end of motion reached,
|
||||
in_motion = False # Stop motion and
|
||||
p1 = p2 = cur_pos = next_pos # Set to new position
|
||||
move_duration = random.uniform(0.5, 1.5) # Wait this long
|
||||
else: # Still moving
|
||||
# Determine p1, p2 position in time
|
||||
delta = (next_pos[0] - cur_pos[0], next_pos[1] - cur_pos[1])
|
||||
ratio = elapsed / move_duration
|
||||
if ratio < 0.6: # First 60% of move time
|
||||
# p1 is in motion
|
||||
# Easing function: 3*e^2-2*e^3 0.0 to 1.0
|
||||
e = ratio / 0.6 # 0.0 to 1.0
|
||||
e = 3 * e * e - 2 * e * e * e
|
||||
p1 = (cur_pos[0] + delta[0] * e, cur_pos[1] + delta[1] * e)
|
||||
else: # Last 40% of move time
|
||||
p1 = next_pos # p1 has reached end position
|
||||
if ratio > 0.3: # Last 60% of move time
|
||||
# p2 is in motion
|
||||
e = (ratio - 0.3) / 0.7 # 0.0 to 1.0
|
||||
e = 3 * e * e - 2 * e * e * e # Easing func.
|
||||
p2 = (cur_pos[0] + delta[0] * e, cur_pos[1] + delta[1] * e)
|
||||
else: # First 40% of move time
|
||||
p2 = cur_pos # p2 waits at start position
|
||||
else: # Eye is stopped
|
||||
p1 = p2 = cur_pos # Both foci at current eye position
|
||||
if elapsed > move_duration: # Pause time expired?
|
||||
in_motion = True # Start up new motion!
|
||||
move_start_time = now
|
||||
move_duration = random.uniform(0.15, 0.25)
|
||||
angle = random.uniform(0, math.pi * 2)
|
||||
dist = random.uniform(0, 7.5)
|
||||
next_pos = (
|
||||
9 + math.cos(angle) * dist,
|
||||
7.5 + math.sin(angle) * dist * 0.8,
|
||||
)
|
||||
|
||||
# Draw the raster part of each eye...
|
||||
for eye in eyes:
|
||||
# Allocate/clear the 3X bitmap buffer
|
||||
bitmap = [bytearray(6 * 3) for _ in range(5 * 3)]
|
||||
# Each eye's foci are offset slightly, to fixate toward center
|
||||
p1a = (p1[0] + eye.x_offset, p1[1])
|
||||
p2a = (p2[0] + eye.x_offset, p2[1])
|
||||
# Compute bounding rectangle (in 3X space) of ellipse
|
||||
# (min X, min Y, max X, max Y). Like the ellipse rasterizer,
|
||||
# this isn't optimal, but will suffice.
|
||||
bounds = (
|
||||
max(int(min(p1a[0], p2a[0]) - radius), 0),
|
||||
max(int(min(p1a[1], p2a[1]) - radius), 0, int(upper)),
|
||||
min(int(max(p1a[0], p2a[0]) + radius + 1), 18),
|
||||
min(int(max(p1a[1], p2a[1]) + radius + 1), 15, int(lower) + 1),
|
||||
)
|
||||
rasterize(bitmap, p1a, p2a, bounds) # Render ellipse into buffer
|
||||
# If the eye is currently blinking, and if the top edge of the
|
||||
# eyelid overlaps the bitmap, draw a scanline across the bitmap
|
||||
# and update the bounds rect so the whole width of the bitmap
|
||||
# is scaled.
|
||||
if blink_state and upper >= 0:
|
||||
bitmap[int(upper)] = eyelid
|
||||
bounds = (0, int(upper), 18, bounds[3])
|
||||
eye.smooth(bitmap, bounds) # 1:3 downsampling for eye
|
||||
|
||||
# Matrix and rings share a few pixels. To make the rings take
|
||||
# precedence, they're drawn later. So blink state is revisited now...
|
||||
if blink_state: # In mid-blink?
|
||||
for i in range(13): # Half an LED ring, top-to-bottom...
|
||||
a = min(max(y_pos[i] - upper + 1, 0), 3)
|
||||
b = min(max(lower - y_pos[i] + 1, 0), 3)
|
||||
ratio = a * b / 9 # Proximity of LED to eyelid edges
|
||||
packed = interp(ring_open_color, ring_blink_color, ratio)
|
||||
glasses.left_ring[i] = glasses.right_ring[i] = packed
|
||||
if 0 < i < 12:
|
||||
i = 24 - i # Mirror half-ring to other side
|
||||
glasses.left_ring[i] = glasses.right_ring[i] = packed
|
||||
else:
|
||||
glasses.left_ring.fill(ring_open_color_packed)
|
||||
glasses.right_ring.fill(ring_open_color_packed)
|
||||
|
||||
glasses.show() # Buffered mode MUST use show() to refresh matrix
|
||||
|
||||
except OSError: # See "try" notes above regarding rare I2C errors.
|
||||
print("Restarting")
|
||||
reload()
|
||||
|
||||
frames += 1
|
||||
elapsed = time.monotonic() - start_time
|
||||
print(frames / elapsed)
|
||||
Loading…
Reference in a new issue