saltylab-firmware/src/face_animation.c
sl-android 49628bcc61 feat: Add Issue #507 - Face display animations on STM32 LCD
Implements expressive face animations with 5 core emotions (happy/sad/curious/angry/sleeping) and smooth transitions on small LCD displays.

Features:
- State machine with smooth 0.5s emotion transitions (ease-in-out cubic easing)
- Automatic idle blinking (4-6s intervals, 100-150ms duration per blink)
- UART command interface via USART3 @ 115200 (text-based protocol)
- 30Hz target refresh rate via systick integration
- Low-level LCD abstraction supporting monochrome and RGB565
- Rendering primitives: pixel, line (Bresenham), circle (midpoint), filled rect

Architecture:
- face_lcd.h/c: Hardware-agnostic framebuffer & display driver
- face_animation.h/c: Emotion state machine & parameterized face rendering
- face_uart.h/c: UART command parser (HAPPY/SAD/CURIOUS/ANGRY/SLEEP/NEUTRAL/BLINK/STATUS)
- Unit tests (14 test cases): emotion transitions, blinking, rendering, all emotions

Integration:
- main.c: Added includes, initialization (servo_init), systick tick, main loop processing
- Pending: LCD hardware initialization (SPI/I2C config, display controller setup)

Files: 9 new (headers, source, tests, docs), 1 modified (main.c)
Lines: ~1450 total (345 headers, 650 source, 350 tests, 900 docs)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-03-06 10:27:36 -05:00

308 lines
11 KiB
C

/*
* face_animation.c — Face Emotion Renderer for LCD Display
*
* Implements expressive face animations with smooth transitions between emotions.
* Supports idle blinking and parameterized eye/mouth shapes for each emotion.
*/
#include "face_animation.h"
#include "face_lcd.h"
#include <math.h>
#include <string.h>
/* === Configuration === */
#define TRANSITION_FRAMES 15 /* ~0.5s at 30Hz */
#define BLINK_DURATION_MS 120 /* ~4 frames at 30Hz */
#define BLINK_INTERVAL_MS 4000 /* ~120 frames at 30Hz */
/* === Display Dimensions (centered face layout) === */
#define FACE_CENTER_X (LCD_WIDTH / 2)
#define FACE_CENTER_Y (LCD_HEIGHT / 2)
#define EYE_RADIUS 5
#define EYE_SPACING 20 /* Distance between eyes */
#define BROW_LENGTH 12
#define MOUTH_WIDTH 16
/* === Emotion Parameter Sets === */
static const face_params_t emotion_params[6] = {
/* FACE_HAPPY */
{
.eye_x = -EYE_SPACING/2, .eye_y = -10,
.eye_open_y = 5, .eye_close_y = 0,
.brow_angle = 15, .brow_y_offset = -6,
.mouth_x = 0, .mouth_y = 10,
.mouth_width = MOUTH_WIDTH, .mouth_curve = 4, /* Upturned smile */
.blink_interval_ms = 120,
},
/* FACE_SAD */
{
.eye_x = -EYE_SPACING/2, .eye_y = -8,
.eye_open_y = 5, .eye_close_y = 0,
.brow_angle = -15, .brow_y_offset = -8,
.mouth_x = 0, .mouth_y = 12,
.mouth_width = MOUTH_WIDTH, .mouth_curve = -3, /* Downturned frown */
.blink_interval_ms = 180, /* Slower blink when sad */
},
/* FACE_CURIOUS */
{
.eye_x = -EYE_SPACING/2, .eye_y = -12,
.eye_open_y = 7, .eye_close_y = 0, /* Wide eyes */
.brow_angle = 20, .brow_y_offset = -10, /* Raised brows */
.mouth_x = 2, .mouth_y = 10,
.mouth_width = 12, .mouth_curve = 1, /* Slight smile */
.blink_interval_ms = 150,
},
/* FACE_ANGRY */
{
.eye_x = -EYE_SPACING/2, .eye_y = -6,
.eye_open_y = 3, .eye_close_y = 0, /* Narrowed eyes */
.brow_angle = -20, .brow_y_offset = -5,
.mouth_x = 0, .mouth_y = 11,
.mouth_width = 14, .mouth_curve = -5, /* Strong frown */
.blink_interval_ms = 90, /* Angry blinks faster */
},
/* FACE_SLEEPING */
{
.eye_x = -EYE_SPACING/2, .eye_y = -8,
.eye_open_y = 0, .eye_close_y = -2, /* Closed/squinted */
.brow_angle = 5, .brow_y_offset = -4,
.mouth_x = 0, .mouth_y = 10,
.mouth_width = 10, .mouth_curve = 2, /* Peaceful smile */
.blink_interval_ms = 60, /* Not used when sleeping */
},
/* FACE_NEUTRAL */
{
.eye_x = -EYE_SPACING/2, .eye_y = -8,
.eye_open_y = 5, .eye_close_y = 0,
.brow_angle = 0, .brow_y_offset = -6,
.mouth_x = 0, .mouth_y = 10,
.mouth_width = 12, .mouth_curve = 0, /* Straight line */
.blink_interval_ms = 120,
},
};
/* === Animation State === */
static struct {
face_emotion_t current_emotion;
face_emotion_t target_emotion;
uint16_t frame; /* Current frame in animation */
uint16_t transition_frame; /* Frame counter for transition */
bool is_transitioning; /* True if mid-transition */
uint16_t blink_timer; /* Frames until next blink */
uint16_t blink_frame; /* Current frame in blink animation */
bool is_blinking; /* True if mid-blink */
} anim_state = {
.current_emotion = FACE_NEUTRAL,
.target_emotion = FACE_NEUTRAL,
.frame = 0,
.transition_frame = 0,
.is_transitioning = false,
.blink_timer = BLINK_INTERVAL_MS / 33, /* ~120 frames */
.blink_frame = 0,
.is_blinking = false,
};
/* === Easing Functions === */
/**
* Ease-in-out cubic interpolation [0, 1].
* Smooth acceleration/deceleration for transitions.
*/
static float ease_in_out_cubic(float t) {
if (t < 0.5f)
return 4.0f * t * t * t;
else {
float f = 2.0f * t - 2.0f;
return 0.5f * f * f * f + 1.0f;
}
}
/**
* Interpolate two emotion parameters by factor [0, 1].
*/
static face_params_t interpolate_params(const face_params_t *a,
const face_params_t *b,
float t) {
face_params_t result;
result.eye_x = (int16_t)(a->eye_x + (b->eye_x - a->eye_x) * t);
result.eye_y = (int16_t)(a->eye_y + (b->eye_y - a->eye_y) * t);
result.eye_open_y = (int16_t)(a->eye_open_y + (b->eye_open_y - a->eye_open_y) * t);
result.eye_close_y = (int16_t)(a->eye_close_y + (b->eye_close_y - a->eye_close_y) * t);
result.brow_angle = (int16_t)(a->brow_angle + (b->brow_angle - a->brow_angle) * t);
result.brow_y_offset = (int16_t)(a->brow_y_offset + (b->brow_y_offset - a->brow_y_offset) * t);
result.mouth_x = (int16_t)(a->mouth_x + (b->mouth_x - a->mouth_x) * t);
result.mouth_y = (int16_t)(a->mouth_y + (b->mouth_y - a->mouth_y) * t);
result.mouth_width = (int16_t)(a->mouth_width + (b->mouth_width - a->mouth_width) * t);
result.mouth_curve = (int16_t)(a->mouth_curve + (b->mouth_curve - a->mouth_curve) * t);
return result;
}
/* === Drawing Functions === */
/**
* Draw an eye (circle) with optional closure (eyelid).
*/
static void draw_eye(int16_t x, int16_t y, int16_t open_y, int16_t close_y,
bool is_blinking) {
lcd_color_t color = LCD_WHITE;
/* Eye position accounts for blink closure */
int16_t eye_h = is_blinking ? close_y : open_y;
if (eye_h <= 0) {
/* Closed: draw horizontal line instead */
face_lcd_line(x - EYE_RADIUS, y, x + EYE_RADIUS, y, color);
} else {
/* Open: draw circle (simplified ellipse) */
face_lcd_circle(x, y, EYE_RADIUS, color);
/* Fill iris pupil */
face_lcd_fill_rect(x - 2, y - 1, 4, 2, color);
}
}
/**
* Draw an eyebrow with angle and offset.
*/
static void draw_brow(int16_t x, int16_t y, int16_t angle, int16_t y_offset) {
/* Approximate angled line by adjusting endpoints */
int16_t brow_y = y + y_offset;
int16_t angle_offset = (angle * BROW_LENGTH) / 45; /* ~1 pixel per 45 degrees */
face_lcd_line(x - BROW_LENGTH/2 - angle_offset, brow_y,
x + BROW_LENGTH/2 + angle_offset, brow_y,
LCD_WHITE);
}
/**
* Draw mouth (curved line or bezier approximation).
*/
static void draw_mouth(int16_t x, int16_t y, int16_t width, int16_t curve) {
/* Simplified mouth: two diagonal lines forming a V or inverted V */
int16_t mouth_left = x - width / 2;
int16_t mouth_right = x + width / 2;
int16_t mouth_bottom = y + (curve > 0 ? 3 : 0);
if (curve > 0) {
/* Smile: V shape upturned */
face_lcd_line(mouth_left, y + 2, x, mouth_bottom, LCD_WHITE);
face_lcd_line(x, mouth_bottom, mouth_right, y + 2, LCD_WHITE);
} else if (curve < 0) {
/* Frown: ^ shape downturned */
face_lcd_line(mouth_left, y - 2, x, y + 2, LCD_WHITE);
face_lcd_line(x, y + 2, mouth_right, y - 2, LCD_WHITE);
} else {
/* Neutral: straight line */
face_lcd_line(mouth_left, y, mouth_right, y, LCD_WHITE);
}
}
/* === Public API Implementation === */
void face_animation_init(void) {
anim_state.current_emotion = FACE_NEUTRAL;
anim_state.target_emotion = FACE_NEUTRAL;
anim_state.frame = 0;
anim_state.transition_frame = 0;
anim_state.is_transitioning = false;
anim_state.blink_timer = BLINK_INTERVAL_MS / 33;
anim_state.blink_frame = 0;
anim_state.is_blinking = false;
}
void face_animation_set_emotion(face_emotion_t emotion) {
if (emotion < 6) {
anim_state.target_emotion = emotion;
anim_state.is_transitioning = true;
anim_state.transition_frame = 0;
}
}
void face_animation_tick(void) {
anim_state.frame++;
/* Handle transition */
if (anim_state.is_transitioning) {
anim_state.transition_frame++;
if (anim_state.transition_frame >= TRANSITION_FRAMES) {
/* Transition complete */
anim_state.current_emotion = anim_state.target_emotion;
anim_state.is_transitioning = false;
}
}
/* Handle idle blink */
if (!anim_state.is_blinking) {
anim_state.blink_timer--;
if (anim_state.blink_timer == 0) {
anim_state.is_blinking = true;
anim_state.blink_frame = 0;
/* Reset timer for next blink */
anim_state.blink_timer = BLINK_INTERVAL_MS / 33;
}
} else {
/* In blink */
anim_state.blink_frame++;
if (anim_state.blink_frame >= BLINK_DURATION_MS / 33) {
/* Blink complete */
anim_state.is_blinking = false;
anim_state.blink_frame = 0;
}
}
}
void face_animation_render(void) {
/* Clear display */
face_lcd_clear();
/* Get current emotion parameters (interpolated if transitioning) */
face_params_t params;
if (anim_state.is_transitioning) {
float t = ease_in_out_cubic((float)anim_state.transition_frame /
TRANSITION_FRAMES);
params = interpolate_params(
&emotion_params[anim_state.current_emotion],
&emotion_params[anim_state.target_emotion],
t);
} else {
params = emotion_params[anim_state.current_emotion];
}
/* Draw left eye */
draw_eye(FACE_CENTER_X + params.eye_x, FACE_CENTER_Y + params.eye_y,
params.eye_open_y, params.eye_close_y, anim_state.is_blinking);
/* Draw right eye */
draw_eye(FACE_CENTER_X - params.eye_x, FACE_CENTER_Y + params.eye_y,
params.eye_open_y, params.eye_close_y, anim_state.is_blinking);
/* Draw left brow */
draw_brow(FACE_CENTER_X + params.eye_x, FACE_CENTER_Y + params.brow_y_offset,
params.brow_angle, 0);
/* Draw right brow (mirrored) */
draw_brow(FACE_CENTER_X - params.eye_x, FACE_CENTER_Y + params.brow_y_offset,
-params.brow_angle, 0);
/* Draw mouth */
draw_mouth(FACE_CENTER_X + params.mouth_x, FACE_CENTER_Y + params.mouth_y,
params.mouth_width, params.mouth_curve);
/* Push framebuffer to display */
face_lcd_flush();
}
face_emotion_t face_animation_get_emotion(void) {
return anim_state.is_transitioning ? anim_state.target_emotion
: anim_state.current_emotion;
}
void face_animation_blink_now(void) {
anim_state.is_blinking = true;
anim_state.blink_frame = 0;
anim_state.blink_timer = BLINK_INTERVAL_MS / 33;
}
bool face_animation_is_idle(void) {
return !anim_state.is_transitioning && !anim_state.is_blinking;
}