diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml old mode 100644 new mode 100755 index 4b03432f0..c1bb607c4 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -73,6 +73,8 @@ jobs: target: esp32s3 - path: 'components/event_manager/example' target: esp32 + - path: 'components/expressive_eyes/example' + target: esp32s3 - path: 'components/file_system/example' target: esp32 - path: 'components/filters/example' diff --git a/.github/workflows/upload_components.yml b/.github/workflows/upload_components.yml old mode 100644 new mode 100755 index a84c47a80..f0d27c8e9 --- a/.github/workflows/upload_components.yml +++ b/.github/workflows/upload_components.yml @@ -59,6 +59,7 @@ jobs: components/esp-box components/esp32-timer-cam components/event_manager + components/expressive_eyes components/file_system components/filters components/format diff --git a/components/expressive_eyes/CMakeLists.txt b/components/expressive_eyes/CMakeLists.txt new file mode 100644 index 000000000..f8bef1050 --- /dev/null +++ b/components/expressive_eyes/CMakeLists.txt @@ -0,0 +1,5 @@ +idf_component_register( + INCLUDE_DIRS "include" + SRC_DIRS "src" + REQUIRES base_component +) diff --git a/components/expressive_eyes/README.md b/components/expressive_eyes/README.md new file mode 100755 index 000000000..9e872920e --- /dev/null +++ b/components/expressive_eyes/README.md @@ -0,0 +1,56 @@ +# Expressive Eyes Component + +[![Badge](https://components.espressif.com/components/espp/expressive_eyes/badge.svg)](https://components.espressif.com/components/espp/expressive_eyes) + +The `ExpressiveEyes` component provides animated expressive eyes for displays +using simple blob shapes. Eyes can blink, look around, change expression, and +display various emotions with smooth animations. + +The component uses a callback-based drawing system, allowing you to implement +custom renderers for different display types and visual styles. + +https://github.com/user-attachments/assets/966f5e63-aeb8-4fc3-b915-c4936fee7a1f + +## Features + +- Multiple expressions (happy, sad, angry, surprised, neutral, sleepy, bored, wink_left, wink_right) +- Smooth eye movement with look_at positioning +- Automatic blinking with configurable intervals +- Optional pupils with physics-based movement +- Eyebrows and cheeks for enhanced expressions +- Smooth expression transitions with blending +- Customizable colors and sizes +- Frame-based animation system + + + + + + + + + + + + + + + + + + + + + + + + + + +
happy imagesad image
angry imagesurprised image
sleepy imagebored image
wink left imagewink right image
looking left imagelooking right image
looking up imagelooking down image
+ +## Example + +The [example](./example) demonstrates the expressive eyes component with two +different drawer implementations (full-featured realistic eyes and monochrome +blue eyes) running on various ESP32 display boards. diff --git a/components/expressive_eyes/example/CMakeLists.txt b/components/expressive_eyes/example/CMakeLists.txt new file mode 100644 index 000000000..e27295bc2 --- /dev/null +++ b/components/expressive_eyes/example/CMakeLists.txt @@ -0,0 +1,22 @@ +# The following lines of boilerplate have to be in your project's CMakeLists +# in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.20) + +set(ENV{IDF_COMPONENT_MANAGER} "0") +include($ENV{IDF_PATH}/tools/cmake/project.cmake) + +# add the component directories that we want to use +set(EXTRA_COMPONENT_DIRS + "../../../components/" +) + +set( + COMPONENTS + "main esptool_py expressive_eyes display task esp-box matouch-rotary-display ws-s3-touch" + CACHE STRING + "List of components to include" + ) + +project(expressive_eyes_example) + +set(CMAKE_CXX_STANDARD 20) diff --git a/components/expressive_eyes/example/README.md b/components/expressive_eyes/example/README.md new file mode 100755 index 000000000..bce2f13fc --- /dev/null +++ b/components/expressive_eyes/example/README.md @@ -0,0 +1,184 @@ +# Expressive Eyes Example + +This example demonstrates animated expressive eyes on various ESP32 display +boards. It showcases different expressions, eye movements, and includes a +continuous random demo mode perfect for a desk display. + + + + + + + + + + + + + + + + + + + + + + + + + + +
happy imagesad image
angry imagesurprised image
sleepy imagebored image
wink left imagewink right image
looking left imagelooking right image
looking up imagelooking down image
+ +Videos: + +https://github.com/user-attachments/assets/966f5e63-aeb8-4fc3-b915-c4936fee7a1f + +## How to use example + +### Hardware Required + +This example can be configured to run on the following dev boards: +- ESP32-S3-BOX / ESP32-S3-BOX-3 +- MaTouch Rotary Display +- WS-S3-Touch + +### Configure the project + +``` +idf.py menuconfig +``` + +Navigate to `Expressive Eyes Example Configuration` to: +- Select your board +- Choose drawing method (Full Featured or Monochrome Blue) + +### Build and Flash + +Build the project and flash it to the board, then run monitor tool to view serial output: + +``` +idf.py -p PORT flash monitor +``` + +(Replace PORT with the name of the serial port to use.) + +(To exit the serial monitor, type ``Ctrl-]``.) + +See the Getting Started Guide for full steps to configure and use ESP-IDF to build projects. + +## Example Breakdown + +The example has three phases: + +### 1. Expression Showcase + +Cycles through all available expressions (3 seconds each): +- Neutral +- Happy +- Sad +- Angry +- Surprised + +### 2. Look Direction Demo + +Demonstrates the look_at functionality by looking in different directions (1.5 seconds each): +- Left +- Right +- Up +- Down +- Center + +### 3. Random Demo Mode + +Enters continuous random demo mode for engaging desk display: +- **Natural behavior**: Mostly stays neutral with occasional emotional expressions +- **Random looking**: Eyes look in random directions every 2-6 seconds +- **Expression changes**: Randomly changes expression every 5-15 seconds + - 50% neutral (most common) + - 20% happy + - 10% surprised + - 10% sad + - 10% angry +- **Automatic blinking**: Eyes blink naturally at random intervals + +The random demo mode runs indefinitely, making it perfect for a continuous desk display. + +## Drawer Implementations + +The example includes two drawer implementations: + +### Full Featured Drawer +- White eye background with black pupils +- Eyebrows drawn as rotated lines with rounded caps +- Cheeks for emotional expressions +- Respects LVGL theme background color + +### Monochrome Blue Drawer +- Electric blue (#00BFFF) eyes on black background +- No pupils (solid colored eyes) +- Minimalist aesthetic perfect for sci-fi interfaces +- Black eyebrows and cheeks + +See [README_DRAWERS.md](./main/README_DRAWERS.md) for details on creating custom drawer implementations. + +## Example Output + + + + + + + + + + + + + + + + + + + + + + + + + + +
happy imagesad image
angry imagesurprised image
sleepy imagebored image
wink left imagewink right image
looking left imagelooking right image
looking up imagelooking down image
+ +image + +```console +I (886) main_task: Calling app_main() +[Expressive Eyes Example/I][0.889]: Starting Expressive Eyes Example +[WsS3Touch/I][0.899]: Initializing LCD... +[WsS3Touch/I][0.900]: Initializing SPI... +[WsS3Touch/I][0.906]: Adding device to SPI bus... +[WsS3Touch/I][0.911]: Initializing the display driver... +[WsS3Touch/I][1.321]: Display driver initialized successfully! +W (1322) ledc: the binded timer can't keep alive in sleep +[WsS3Touch/I][1.324]: Initializing display with pixel buffer size: 12000 +[WsS3Touch/I][1.334]: Display initialized successfully! +[Expressive Eyes Example/I][1.335]: Display size: 240x280 +[Expressive Eyes Example/I][1.342]: Using Monochrome Blue drawer +[Expressive Eyes Example/I][1.392]: Expressive eyes initialized +[Expressive Eyes Example/I][1.392]: Testing different expressions... +[Expressive Eyes Example/I][1.395]: Expression: Neutral +[Expressive Eyes Example/I][11.928]: Expression: Happy +[Expressive Eyes Example/I][22.548]: Expression: Sad +[Expressive Eyes Example/I][33.168]: Expression: Angry +[Expressive Eyes Example/I][43.788]: Expression: Surprised +[Expressive Eyes Example/I][54.407]: Testing look_at functionality +[Expressive Eyes Example/I][54.408]: Looking left +[Expressive Eyes Example/I][59.717]: Looking right +[Expressive Eyes Example/I][65.028]: Looking up +[Expressive Eyes Example/I][70.337]: Looking down +[Expressive Eyes Example/I][75.648]: Looking center +[Expressive Eyes Example/I][80.957]: Starting random demo mode - will run continuously +``` diff --git a/components/expressive_eyes/example/main/CMakeLists.txt b/components/expressive_eyes/example/main/CMakeLists.txt new file mode 100644 index 000000000..a941e22ba --- /dev/null +++ b/components/expressive_eyes/example/main/CMakeLists.txt @@ -0,0 +1,2 @@ +idf_component_register(SRC_DIRS "." + INCLUDE_DIRS ".") diff --git a/components/expressive_eyes/example/main/Kconfig.projbuild b/components/expressive_eyes/example/main/Kconfig.projbuild new file mode 100755 index 000000000..a5cdd75c9 --- /dev/null +++ b/components/expressive_eyes/example/main/Kconfig.projbuild @@ -0,0 +1,45 @@ +menu "Expressive Eyes Example Configuration" + + choice EXPRESSIVE_EYES_BOARD + prompt "Select Board" + default EXPRESSIVE_EYES_BOARD_ESP_BOX + help + Select the hardware board to run the example on. + + config EXPRESSIVE_EYES_BOARD_ESP_BOX + bool "ESP-BOX" + help + Use ESP32-S3-BOX or ESP32-S3-BOX-3 development board. + + config EXPRESSIVE_EYES_BOARD_MATOUCH_ROTARY + bool "MaTouch Rotary Display" + help + Use MaTouch Rotary Display board. + + config EXPRESSIVE_EYES_BOARD_WS_S3_TOUCH + bool "WS-S3-Touch BSP" + help + Use WS-S3-Touch development board. + + endchoice + + choice EXPRESSIVE_EYES_DRAWING_METHOD + prompt "Select Drawing Method" + default EXPRESSIVE_EYES_MONOCHROME_BLUE if EXPRESSIVE_EYES_BOARD_WS_S3_TOUCH + default EXPRESSIVE_EYES_FULL_FEATURED + help + Select the eye drawing implementation to use. + + config EXPRESSIVE_EYES_FULL_FEATURED + bool "Full Featured (White eyes with black pupils)" + help + Draw realistic eyes with white background, black pupils, eyebrows, and cheeks. + + config EXPRESSIVE_EYES_MONOCHROME_BLUE + bool "Monochrome Blue (Electric blue on black)" + help + Draw simple monochrome eyes in electric blue on black background without pupils. + + endchoice + +endmenu diff --git a/components/expressive_eyes/example/main/expressive_eyes_example.cpp b/components/expressive_eyes/example/main/expressive_eyes_example.cpp new file mode 100644 index 000000000..763bee1cc --- /dev/null +++ b/components/expressive_eyes/example/main/expressive_eyes_example.cpp @@ -0,0 +1,265 @@ +#include +#include +#include +#include +#include +#include + +#include "expressive_eyes.hpp" +#include "logger.hpp" +#include "task.hpp" + +// Include drawer implementations +#include "full_featured_drawer.hpp" +#include "monochrome_blue_drawer.hpp" + +// Board-specific includes based on menuconfig selection +#if CONFIG_EXPRESSIVE_EYES_BOARD_ESP_BOX +#include "esp-box.hpp" +using Board = espp::EspBox; +#elif CONFIG_EXPRESSIVE_EYES_BOARD_MATOUCH_ROTARY +#include "matouch-rotary-display.hpp" +using Board = espp::MatouchRotaryDisplay; +#elif CONFIG_EXPRESSIVE_EYES_BOARD_WS_S3_TOUCH +#include "ws-s3-touch.hpp" +using Board = espp::WsS3Touch; +#else +#error "No board selected in menuconfig!" +#endif + +using namespace std::chrono_literals; + +static std::recursive_mutex lvgl_mutex; + +extern "C" void app_main(void) { + espp::Logger logger({.tag = "Expressive Eyes Example", .level = espp::Logger::Verbosity::INFO}); + + logger.info("Starting Expressive Eyes Example"); + + //! [expressive eyes example] + // Initialize the board + Board &board = Board::get(); + board.set_log_level(espp::Logger::Verbosity::INFO); + + // Initialize the LCD + if (!board.initialize_lcd()) { + logger.error("Failed to initialize LCD!"); + return; + } + + // Set up pixel buffer (50 lines) + static constexpr size_t pixel_buffer_size = board.lcd_width() * 50; + + // Initialize the display + if (!board.initialize_display(pixel_buffer_size)) { + logger.error("Failed to initialize display!"); + return; + } + + // Get screen dimensions + int screen_width = board.lcd_width(); + int screen_height = board.lcd_height(); + + logger.info("Display size: {}x{}", screen_width, screen_height); + + // Disable scrollbars on screen + lv_obj_clear_flag(lv_screen_active(), LV_OBJ_FLAG_SCROLLABLE); + + // Create main canvas for drawing everything + lv_obj_t *canvas = lv_canvas_create(lv_screen_active()); + + // Allocate buffer for main canvas (RGB565 = 2 bytes per pixel) + static lv_color_t *canvas_buffer = nullptr; + canvas_buffer = (lv_color_t *)heap_caps_malloc(screen_width * screen_height * sizeof(lv_color_t), + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!canvas_buffer) { + logger.error("Failed to allocate canvas buffer!"); + return; + } + + lv_canvas_set_buffer(canvas, canvas_buffer, screen_width, screen_height, LV_COLOR_FORMAT_RGB565); + lv_obj_center(canvas); + + // Create the drawer based on menuconfig selection + std::unique_ptr drawer; + +#if CONFIG_EXPRESSIVE_EYES_FULL_FEATURED + logger.info("Using Full Featured drawer"); + drawer = std::make_unique( + eye_drawer::FullFeaturedDrawer::Config{.screen_width = screen_width, + .screen_height = screen_height, + .canvas = canvas, + .canvas_buffer = canvas_buffer, + .lvgl_mutex = lvgl_mutex}); +#elif CONFIG_EXPRESSIVE_EYES_MONOCHROME_BLUE + logger.info("Using Monochrome Blue drawer"); + drawer = std::make_unique( + eye_drawer::MonochromeBlueDrawer::Config{.screen_width = screen_width, + .screen_height = screen_height, + .canvas = canvas, + .canvas_buffer = canvas_buffer, + .lvgl_mutex = lvgl_mutex}); +#else +#error "No drawing method selected in menuconfig!" +#endif + + // Get the draw callback from the drawer + auto draw_eyes = drawer->get_draw_callback(); + + // Configure expressive eyes with adaptive sizing - make eyes larger + int large_eye_width = screen_width * 0.35f; // 35% of screen width each + int large_eye_height = screen_height * 0.55f; // 55% of screen height + int large_spacing = screen_width * 0.55f; // Space between eye centers + + espp::ExpressiveEyes::Config config{.screen_width = screen_width, + .screen_height = screen_height, + .eye_spacing = large_spacing, + .eye_width = large_eye_width, + .eye_height = large_eye_height, + .blink_duration = 0.12f, + .blink_interval = 4.0f, + .enable_auto_blink = true, + .enable_pupil_physics = true, + .on_draw = draw_eyes, + .log_level = espp::Logger::Verbosity::WARN}; + + espp::ExpressiveEyes eyes(config); + + // start a simple thread to do the lv_task_handler every 16ms + espp::Task lv_task({.callback = [](std::mutex &m, std::condition_variable &cv) -> bool { + { + std::lock_guard lock(lvgl_mutex); + lv_task_handler(); + } + std::unique_lock lock(m); + cv.wait_for(lock, 16ms); + return false; + }, + .task_config = { + .name = "lv_task", + .stack_size_bytes = 16 * 1024, + }}); + lv_task.start(); + + logger.info("Expressive eyes initialized"); + + // Test different expressions using array iteration + logger.info("Testing different expressions..."); + + // Cycle through each expression preset to demonstrate different emotional states + const espp::ExpressiveEyes::Expression expressions[] = { + espp::ExpressiveEyes::Expression::NEUTRAL, espp::ExpressiveEyes::Expression::HAPPY, + espp::ExpressiveEyes::Expression::SAD, espp::ExpressiveEyes::Expression::ANGRY, + espp::ExpressiveEyes::Expression::SURPRISED, espp::ExpressiveEyes::Expression::SLEEPY, + espp::ExpressiveEyes::Expression::BORED, espp::ExpressiveEyes::Expression::WINK_LEFT, + espp::ExpressiveEyes::Expression::WINK_RIGHT}; + + for (const auto &expr : expressions) { + logger.info("Expression: {}", expr); + eyes.set_expression(expr); + auto start = std::chrono::steady_clock::now(); + while (std::chrono::duration(std::chrono::steady_clock::now() - start).count() < 3.0f) { + eyes.update(0.016f); // 16ms update at ~60fps + std::this_thread::sleep_for(16ms); + } + } + + // Test look_at functionality using array iteration + logger.info("Testing look_at functionality"); + eyes.set_expression(espp::ExpressiveEyes::Expression::NEUTRAL); + + // Demonstrate directional looking in the 4 cardinal directions plus center + struct LookDirection { + const char *name; + float x; ///< Horizontal direction: -1.0 = left, 1.0 = right, 0 = center + float y; ///< Vertical direction: -1.0 = up, 1.0 = down, 0 = center + }; + + const LookDirection look_directions[] = {{"left", -1.0f, 0.0f}, + {"right", 1.0f, 0.0f}, + {"up", 0.0f, -1.0f}, + {"down", 0.0f, 1.0f}, + {"center", 0.0f, 0.0f}}; + + for (const auto &dir : look_directions) { + logger.info("Looking {}", dir.name); + eyes.look_at(dir.x, dir.y); + auto start = std::chrono::steady_clock::now(); + while (std::chrono::duration(std::chrono::steady_clock::now() - start).count() < 1.5f) { + eyes.update(0.016f); // 16ms update at ~60fps + std::this_thread::sleep_for(16ms); + } + } + + // Random demo mode - continuously looks around and changes expressions + logger.info("Starting random demo mode - will run continuously"); + + // Seed random number generator with current time + srand(time(nullptr)); + + // Reset to neutral expression and center gaze + eyes.set_expression(espp::ExpressiveEyes::Expression::NEUTRAL); + eyes.look_at(0.0f, 0.0f); + + // Random mode state - tracks when to trigger next action + float time_until_next_look = 2.0f + (rand() % 4000) / 1000.0f; // 2-6 seconds + float time_until_next_expression = 5.0f + (rand() % 10000) / 1000.0f; // 5-15 seconds + float look_timer = 0.0f; + float expression_timer = 0.0f; + + // Animation loop - runs indefinitely for continuous desk display + auto last_time = std::chrono::steady_clock::now(); + while (true) { + auto now = std::chrono::steady_clock::now(); + float dt = std::chrono::duration(now - last_time).count(); + last_time = now; + + // Update timers + look_timer += dt; + expression_timer += dt; + + // Randomly look around - gives eyes natural movement + if (look_timer >= time_until_next_look) { + float look_x = ((rand() % 2000) - 1000) / 1000.0f; // -1.0 to 1.0 + float look_y = ((rand() % 2000) - 1000) / 1000.0f; // -1.0 to 1.0 + eyes.look_at(look_x, look_y); + look_timer = 0.0f; + time_until_next_look = 2.0f + (rand() % 4000) / 1000.0f; // 2-6 seconds + } + + // Randomly change expression (weighted toward neutral for natural behavior) + if (expression_timer >= time_until_next_expression) { + int expr_choice = rand() % 10; + if (expr_choice < 5) { + // 50% chance: stay neutral + eyes.set_expression(espp::ExpressiveEyes::Expression::NEUTRAL); + } else if (expr_choice < 7) { + // 20% chance: happy + eyes.set_expression(espp::ExpressiveEyes::Expression::HAPPY); + } else if (expr_choice < 8) { + // 10% chance: surprised + eyes.set_expression(espp::ExpressiveEyes::Expression::SURPRISED); + } else if (expr_choice < 9) { + // 10% chance: sad + eyes.set_expression(espp::ExpressiveEyes::Expression::SAD); + } else { + // 10% chance: angry + eyes.set_expression(espp::ExpressiveEyes::Expression::ANGRY); + } + expression_timer = 0.0f; + time_until_next_expression = 5.0f + (rand() % 10000) / 1000.0f; // 5-15 seconds + } + + // Update and render eyes (calls draw callback) + eyes.update(dt); + + std::this_thread::sleep_for(16ms); // ~60 FPS + } + //! [expressive eyes example] + + logger.info("Expressive Eyes example complete!"); + + while (true) { + std::this_thread::sleep_for(1s); + } +} diff --git a/components/expressive_eyes/example/main/eye_drawer.hpp b/components/expressive_eyes/example/main/eye_drawer.hpp new file mode 100644 index 000000000..2ca411fb1 --- /dev/null +++ b/components/expressive_eyes/example/main/eye_drawer.hpp @@ -0,0 +1,39 @@ +#pragma once + +#include "expressive_eyes.hpp" +#include + +#include + +namespace eye_drawer { + +/** + * @brief Base interface for eye drawing implementations + * + * Defines the interface that all eye drawer implementations must follow. + * Drawer implementations receive eye state data from ExpressiveEyes and + * are responsible for rendering the eyes using their chosen graphics API. + * + * @note Implementations should use thread-safe operations when drawing + * (e.g., lock LVGL mutex before LVGL calls). + */ +struct EyeDrawer { + virtual ~EyeDrawer() = default; + + /** + * @brief Callback function type for drawing eyes + * @param left_eye Left eye state data + * @param right_eye Right eye state data + */ + typedef std::function + DrawCallback; + + /** + * @brief Get the draw callback function + * @return The callback function to be used with ExpressiveEyes + */ + virtual DrawCallback get_draw_callback() = 0; +}; + +} // namespace eye_drawer diff --git a/components/expressive_eyes/example/main/full_featured_drawer.hpp b/components/expressive_eyes/example/main/full_featured_drawer.hpp new file mode 100644 index 000000000..25cf6b6df --- /dev/null +++ b/components/expressive_eyes/example/main/full_featured_drawer.hpp @@ -0,0 +1,217 @@ +#pragma once + +#include "eye_drawer.hpp" +#include +#include + +#include + +namespace eye_drawer { + +/// \brief Full-featured eye drawer with white eyes, black pupils, and eyebrows +/// \details Draws realistic eyes with: +/// - White eye background +/// - Black pupils with position control +/// - Eyebrows with rotation (cut out from the eye) +/// - Cheeks (cut out from the eye) +class FullFeaturedDrawer : public EyeDrawer { +public: + struct Config { + int screen_width; + int screen_height; + lv_obj_t *canvas; + lv_color_t *canvas_buffer; + std::recursive_mutex &lvgl_mutex; + }; + + /** + * @brief Construct full-featured drawer + * @param config Configuration structure + */ + explicit FullFeaturedDrawer(const Config &config) + : screen_width_(config.screen_width) + , screen_height_(config.screen_height) + , canvas_(config.canvas) + , canvas_buffer_(config.canvas_buffer) + , lvgl_mutex_(config.lvgl_mutex) { + // Calculate eye dimensions + original_eye_height_ = screen_height_ * 0.55f; + eye_base_width_ = screen_width_ * 0.35f; + pupil_size_ = static_cast(std::min(eye_base_width_, original_eye_height_) * 0.3f); + } + + virtual ~FullFeaturedDrawer() override { + // nothing to clean up + } + + virtual DrawCallback get_draw_callback() override { + return [this](const espp::ExpressiveEyes::EyeState &left, + const espp::ExpressiveEyes::EyeState &right) { + std::lock_guard lock(lvgl_mutex_); + + // Get background color + lv_color_t bg_color = lv_obj_get_style_bg_color(lv_screen_active(), LV_PART_MAIN); + + // Clear canvas with background color + lv_canvas_fill_bg(canvas_, bg_color, LV_OPA_COVER); + + // Draw both eyes + draw_single_eye(left, true, bg_color); + draw_single_eye(right, false, bg_color); + }; + } + +private: + /** + * @brief Draw a filled ellipse using LVGL layer API + * @param cx Center X coordinate + * @param cy Center Y coordinate + * @param width Ellipse width + * @param height Ellipse height + * @param color Fill color + */ + void draw_ellipse(int cx, int cy, int width, int height, lv_color_t color) { + lv_layer_t layer; + lv_canvas_init_layer(canvas_, &layer); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.bg_color = color; + rect_dsc.bg_opa = LV_OPA_COVER; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.border_width = 0; + + lv_area_t area; + area.x1 = cx - width / 2; + area.y1 = cy - height / 2; + area.x2 = cx + width / 2; + area.y2 = cy + height / 2; + + lv_draw_rect(&layer, &rect_dsc, &area); + lv_canvas_finish_layer(canvas_, &layer); + } + + /** + * @brief Draw a single eye with all components + * @param eye_state Eye state data from ExpressiveEyes + * @param is_left True if left eye, false if right eye (affects eyebrow mirroring) + * @param bg_color Background color for eyebrows and cheeks + */ + void draw_single_eye(const espp::ExpressiveEyes::EyeState &eye_state, bool is_left, + lv_color_t bg_color) { + // Draw eye white + draw_ellipse(eye_state.x, eye_state.y, eye_state.width, eye_state.height, lv_color_white()); + + // Draw pupil (use original height so blink doesn't move pupil) + // Only draw if eye is open enough (not blinking) + float openness = eye_state.height / static_cast(original_eye_height_); + if (eye_state.expression.pupil.enabled && openness > 0.3f) { + int px_offset = static_cast(eye_state.expression.pupil.x * eye_state.width * 0.3f); + int py_offset = static_cast(eye_state.expression.pupil.y * original_eye_height_ * 0.3f); + int pupil_x = eye_state.x + px_offset; + int pupil_y = eye_state.y + py_offset; + + lv_layer_t layer; + lv_canvas_init_layer(canvas_, &layer); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.bg_color = lv_color_black(); + rect_dsc.bg_opa = LV_OPA_COVER; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.border_width = 0; + + lv_area_t area; + int r = pupil_size_ / 2; + area.x1 = pupil_x - r; + area.y1 = pupil_y - r; + area.x2 = pupil_x + r; + area.y2 = pupil_y + r; + + lv_draw_rect(&layer, &rect_dsc, &area); + lv_canvas_finish_layer(canvas_, &layer); + } + + // Draw eyebrow as rotated line + if (eye_state.expression.eyebrow.enabled) { + int brow_width = + static_cast(eye_base_width_ * eye_state.expression.eyebrow.width * 1.5f); + int brow_height = + static_cast(eye_base_width_ * eye_state.expression.eyebrow.thickness * 4.0f); + int brow_y = eye_state.y - static_cast(original_eye_height_ * 0.4f); + + // For left eye, positive angle tilts left side down (clockwise rotation) + // For right eye, positive angle tilts right side down (counter-clockwise rotation) + float angle_rad = eye_state.expression.eyebrow.angle; // Already in radians + if (!is_left) + angle_rad = -angle_rad; // Mirror for right eye + + // Calculate the two endpoints of the line + float half_w = brow_width / 2.0f; + float cos_a = std::cos(angle_rad); + float sin_a = std::sin(angle_rad); + + lv_point_precise_t p1, p2; + p1.x = eye_state.x - half_w * cos_a; + p1.y = brow_y - half_w * sin_a; + p2.x = eye_state.x + half_w * cos_a; + p2.y = brow_y + half_w * sin_a; + + lv_layer_t layer; + lv_canvas_init_layer(canvas_, &layer); + + lv_draw_line_dsc_t line_dsc; + lv_draw_line_dsc_init(&line_dsc); + line_dsc.color = bg_color; + line_dsc.width = brow_height; + line_dsc.opa = LV_OPA_COVER; + line_dsc.round_start = 1; + line_dsc.round_end = 1; + line_dsc.p1 = p1; + line_dsc.p2 = p2; + + lv_draw_line(&layer, &line_dsc); + + lv_canvas_finish_layer(canvas_, &layer); + } + + // Draw cheek + if (eye_state.expression.cheek.enabled) { + int cheek_width = static_cast(eye_base_width_ * eye_state.expression.cheek.size * 2.5f); + int cheek_height = static_cast(eye_base_width_ * eye_state.expression.cheek.size * 1.5f); + int cheek_y = eye_state.y + static_cast(original_eye_height_ * 0.4f) + + static_cast(eye_state.expression.cheek_offset_y * screen_height_); + + lv_layer_t layer; + lv_canvas_init_layer(canvas_, &layer); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.bg_color = bg_color; + rect_dsc.bg_opa = LV_OPA_COVER; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.border_width = 0; + + lv_area_t area; + area.x1 = eye_state.x - cheek_width / 2; + area.y1 = cheek_y - cheek_height / 2; + area.x2 = eye_state.x + cheek_width / 2; + area.y2 = cheek_y + cheek_height / 2; + + lv_draw_rect(&layer, &rect_dsc, &area); + lv_canvas_finish_layer(canvas_, &layer); + } + } + + int screen_width_; + int screen_height_; + lv_obj_t *canvas_; + lv_color_t *canvas_buffer_; + std::recursive_mutex &lvgl_mutex_; + + int original_eye_height_; + int eye_base_width_; + int pupil_size_; +}; + +} // namespace eye_drawer diff --git a/components/expressive_eyes/example/main/monochrome_blue_drawer.hpp b/components/expressive_eyes/example/main/monochrome_blue_drawer.hpp new file mode 100644 index 000000000..a867c735c --- /dev/null +++ b/components/expressive_eyes/example/main/monochrome_blue_drawer.hpp @@ -0,0 +1,183 @@ +#pragma once + +#include "eye_drawer.hpp" +#include +#include + +#include + +namespace eye_drawer { + +/// \brief Monochrome eye drawer with electric blue eyes on black background +/// \details Draws simple eyes with: +/// - Electric blue eye shapes (no pupils) +/// - Black background +/// - Eyebrows in black (cut out from the eye) +/// - Cheeks in black (cut out from eye) +class MonochromeBlueDrawer : public EyeDrawer { +public: + struct Config { + int screen_width; + int screen_height; + lv_color_t color = lv_color_hex(0x00BFFF); // Electric blue color + lv_obj_t *canvas; + lv_color_t *canvas_buffer; + std::recursive_mutex &lvgl_mutex; + }; + + /** + * @brief Construct monochrome blue drawer + * @param config Configuration structure + */ + explicit MonochromeBlueDrawer(const Config &config) + : screen_width_(config.screen_width) + , screen_height_(config.screen_height) + , canvas_(config.canvas) + , canvas_buffer_(config.canvas_buffer) + , lvgl_mutex_(config.lvgl_mutex) + , electric_blue_(config.color) { + // Calculate eye dimensions + original_eye_height_ = screen_height_ * 0.55f; + eye_base_width_ = screen_width_ * 0.35f; + } + + virtual ~MonochromeBlueDrawer() override { + // nothing to clean up + } + + virtual DrawCallback get_draw_callback() override { + return [this](const espp::ExpressiveEyes::EyeState &left, + const espp::ExpressiveEyes::EyeState &right) { + std::lock_guard lock(lvgl_mutex_); + + // Clear canvas with black background + lv_canvas_fill_bg(canvas_, lv_color_black(), LV_OPA_COVER); + + // Draw both eyes + draw_single_eye(left, true); + draw_single_eye(right, false); + }; + } + +private: + /** + * @brief Draw a filled ellipse using LVGL layer API + * @param cx Center X coordinate + * @param cy Center Y coordinate + * @param width Ellipse width + * @param height Ellipse height + * @param color Fill color + */ + void draw_ellipse(int cx, int cy, int width, int height, lv_color_t color) { + lv_layer_t layer; + lv_canvas_init_layer(canvas_, &layer); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.bg_color = color; + rect_dsc.bg_opa = LV_OPA_COVER; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.border_width = 0; + + lv_area_t area; + area.x1 = cx - width / 2; + area.y1 = cy - height / 2; + area.x2 = cx + width / 2; + area.y2 = cy + height / 2; + + lv_draw_rect(&layer, &rect_dsc, &area); + lv_canvas_finish_layer(canvas_, &layer); + } + + /** + * @brief Draw a single eye with all components + * @param eye_state Eye state data from ExpressiveEyes + * @param is_left True if left eye, false if right eye (affects eyebrow mirroring) + */ + void draw_single_eye(const espp::ExpressiveEyes::EyeState &eye_state, bool is_left) { + // Draw eye in electric blue (no pupil) + draw_ellipse(eye_state.x, eye_state.y, eye_state.width, eye_state.height, electric_blue_); + + // Draw eyebrow as rotated line + if (eye_state.expression.eyebrow.enabled) { + int brow_width = + static_cast(eye_base_width_ * eye_state.expression.eyebrow.width * 1.5f); + int brow_height = + static_cast(eye_base_width_ * eye_state.expression.eyebrow.thickness * 4.0f); + int brow_y = eye_state.y - static_cast(original_eye_height_ * 0.4f); + + // For left eye, positive angle tilts left side down (clockwise rotation) + // For right eye, positive angle tilts right side down (counter-clockwise rotation) + float angle_rad = eye_state.expression.eyebrow.angle; // Already in radians + if (!is_left) + angle_rad = -angle_rad; // Mirror for right eye + + // Calculate the two endpoints of the line + float half_w = brow_width / 2.0f; + float cos_a = std::cos(angle_rad); + float sin_a = std::sin(angle_rad); + + lv_point_precise_t p1, p2; + p1.x = eye_state.x - half_w * cos_a; + p1.y = brow_y - half_w * sin_a; + p2.x = eye_state.x + half_w * cos_a; + p2.y = brow_y + half_w * sin_a; + + lv_layer_t layer; + lv_canvas_init_layer(canvas_, &layer); + + lv_draw_line_dsc_t line_dsc; + lv_draw_line_dsc_init(&line_dsc); + line_dsc.color = lv_color_black(); + line_dsc.width = brow_height; + line_dsc.opa = LV_OPA_COVER; + line_dsc.round_start = 1; + line_dsc.round_end = 1; + line_dsc.p1 = p1; + line_dsc.p2 = p2; + + lv_draw_line(&layer, &line_dsc); + + lv_canvas_finish_layer(canvas_, &layer); + } + + // Draw cheek in electric blue + if (eye_state.expression.cheek.enabled) { + int cheek_width = static_cast(eye_base_width_ * eye_state.expression.cheek.size * 2.5f); + int cheek_height = static_cast(eye_base_width_ * eye_state.expression.cheek.size * 1.5f); + int cheek_y = eye_state.y + static_cast(original_eye_height_ * 0.4f) + + static_cast(eye_state.expression.cheek_offset_y * screen_height_); + + lv_layer_t layer; + lv_canvas_init_layer(canvas_, &layer); + + lv_draw_rect_dsc_t rect_dsc; + lv_draw_rect_dsc_init(&rect_dsc); + rect_dsc.bg_color = lv_color_black(); + rect_dsc.bg_opa = LV_OPA_COVER; + rect_dsc.radius = LV_RADIUS_CIRCLE; + rect_dsc.border_width = 0; + + lv_area_t area; + area.x1 = eye_state.x - cheek_width / 2; + area.y1 = cheek_y - cheek_height / 2; + area.x2 = eye_state.x + cheek_width / 2; + area.y2 = cheek_y + cheek_height / 2; + + lv_draw_rect(&layer, &rect_dsc, &area); + lv_canvas_finish_layer(canvas_, &layer); + } + } + + int screen_width_; + int screen_height_; + lv_obj_t *canvas_; + lv_color_t *canvas_buffer_; + std::recursive_mutex &lvgl_mutex_; + + int original_eye_height_; + int eye_base_width_; + lv_color_t electric_blue_; +}; + +} // namespace eye_drawer diff --git a/components/expressive_eyes/example/sdkconfig.defaults b/components/expressive_eyes/example/sdkconfig.defaults new file mode 100644 index 000000000..13501caba --- /dev/null +++ b/components/expressive_eyes/example/sdkconfig.defaults @@ -0,0 +1,23 @@ +# ESP32-specific +CONFIG_IDF_TARGET="esp32s3" +CONFIG_ESP32S3_DEFAULT_CPU_FREQ_240=y + +# Common ESP-related +# +CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096 +CONFIG_ESP_MAIN_TASK_STACK_SIZE=8192 + +# Flash size +CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y +CONFIG_ESPTOOLPY_FLASHSIZE="4MB" + +# FreeRTOS +CONFIG_FREERTOS_HZ=1000 + +# PSRAM +CONFIG_SPIRAM=y +CONFIG_SPIRAM_MODE_OCT=y +CONFIG_SPIRAM_SPEED_80M=y + +# Default board selection +CONFIG_EXPRESSIVE_EYES_BOARD_ESP_BOX=y diff --git a/components/expressive_eyes/idf_component.yml b/components/expressive_eyes/idf_component.yml new file mode 100644 index 000000000..93f9e1434 --- /dev/null +++ b/components/expressive_eyes/idf_component.yml @@ -0,0 +1,21 @@ +## IDF Component Manager Manifest File +license: "MIT" +description: "Animated expressive eyes for displays" +url: "https://github.com/esp-cpp/espp/tree/main/components/expressive_eyes" +repository: "git://github.com/esp-cpp/espp.git" +maintainers: + - William Emfinger +documentation: "https://esp-cpp.github.io/espp/display/expressive_eyes.html" +tags: + - cpp + - Animation + - Display + - Expressive + - Eyes + - Face +dependencies: + idf: + version: '>=5.0' + espp/base_component: '>=1.0' +examples: + - path: example diff --git a/components/expressive_eyes/include/expressive_eyes.hpp b/components/expressive_eyes/include/expressive_eyes.hpp new file mode 100644 index 000000000..c5eda8977 --- /dev/null +++ b/components/expressive_eyes/include/expressive_eyes.hpp @@ -0,0 +1,252 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +#include "base_component.hpp" + +namespace espp { +/** + * @brief Expressive Eyes Animation Component + * + * Renders animated expressive eyes using simple blob shapes. Eyes can blink, look around, + * change expression, and display various emotions. + * + * Features: + * - Smooth eye movement and blinking + * - Multiple expressions (happy, sad, angry, surprised, etc.) + * - Optional pupils with physics-based movement + * - Eyebrows and cheeks for enhanced expressions + * - Customizable colors and sizes + * - Frame-based animation system + * + * \section eyes_ex1 Expressive Eyes Example + * \snippet expressive_eyes_example.cpp expressive eyes example + */ +class ExpressiveEyes : public BaseComponent { +public: + /** + * @brief Pupil configuration + */ + struct Pupil { + bool enabled{true}; ///< Whether to draw pupils + float size{0.3f}; ///< Pupil size relative to eye (0.0-1.0) + uint16_t color{0x0000}; ///< Pupil color (black) + float x{0.0f}; ///< Pupil X position (-1.0 to 1.0) + float y{0.0f}; ///< Pupil Y position (-1.0 to 1.0) + }; + + /** + * @brief Eyebrow configuration + */ + struct Eyebrow { + bool enabled{false}; ///< Whether to draw eyebrows + float angle{0.0f}; ///< Eyebrow angle in degrees + float height{0.0f}; ///< Vertical offset relative to eye (-1.0 to 1.0) + float thickness{0.1f}; ///< Eyebrow thickness relative to eye + float width{1.0f}; ///< Eyebrow width relative to eye + uint16_t color{0x0000}; ///< Eyebrow color (black) + }; + + /** + * @brief Cheek configuration (for shaping eye bottom) + */ + struct Cheek { + bool enabled{false}; ///< Whether to draw cheeks + float size{0.3f}; ///< Cheek size relative to eye + }; + + /** + * @brief Complete expression state + */ + struct ExpressionState { + float eye_width_scale{1.0f}; ///< Eye width multiplier + float eye_height_scale{1.0f}; ///< Eye height multiplier + float eye_rotation{0.0f}; ///< Eye rotation in radians + float top_curve{0.5f}; ///< Top eyelid curve (0=flat, 1=round) + float bottom_curve{0.5f}; ///< Bottom eyelid curve (0=flat, 1=round) + float eye_offset_y{0.0f}; ///< Vertical offset for eye position + float cheek_offset_y{0.0f}; ///< Vertical offset for cheek position + Pupil pupil; ///< Pupil configuration + Eyebrow eyebrow; ///< Eyebrow configuration + Cheek cheek; ///< Cheek configuration + }; + + /** + * @brief Single eye render data + */ + struct EyeState { + int x; ///< X coordinate of eye center + int y; ///< Y coordinate of eye center + int width; ///< Current eye width + int height; ///< Current eye height + ExpressionState expression; ///< Expression state for this eye + }; + + /** + * @brief Draw callback function + * @param left_eye Left eye render data + * @param right_eye Right eye render data + */ + typedef std::function draw_callback; + + /** + * @brief Eye expression presets + */ + enum class Expression { + NEUTRAL, ///< Normal open eyes + HAPPY, ///< Squinted happy eyes with raised cheeks + SAD, ///< Droopy sad eyes with angled eyebrows + ANGRY, ///< Angled angry eyes with furrowed eyebrows + SURPRISED, ///< Wide open eyes with raised eyebrows + SLEEPY, ///< Droopy half-closed eyes with low eyebrows + BORED, ///< Half-closed eyes with neutral expression + WINK_LEFT, ///< Left eye closed + WINK_RIGHT, ///< Right eye closed + }; + + /** + * @brief Configuration for expressive eyes + */ + struct Config { + int screen_width{320}; ///< Screen width in pixels + int screen_height{240}; ///< Screen height in pixels + int eye_spacing{100}; ///< Distance between eye centers + int eye_width{60}; ///< Base eye width + int eye_height{80}; ///< Base eye height + float blink_duration{0.12f}; ///< Blink duration in seconds + float blink_interval{4.0f}; ///< Average time between blinks + bool enable_auto_blink{true}; ///< Automatic random blinking + bool enable_pupil_physics{true}; ///< Smooth pupil movement + draw_callback on_draw{nullptr}; ///< Drawing callback + Logger::Verbosity log_level{Logger::Verbosity::WARN}; ///< Log verbosity + }; + + /** + * @brief Construct expressive eyes + * @param config Configuration structure + */ + explicit ExpressiveEyes(const Config &config); + + /** + * @brief Update animation (call this every frame) + * @param dt Delta time since last update in seconds + */ + void update(float dt); + + /** + * @brief Set target look direction + * @param x Horizontal look direction (-1.0 to 1.0, 0=center) + * @param y Vertical look direction (-1.0 to 1.0, 0=center) + */ + void look_at(float x, float y); + + /** + * @brief Set expression + * @param expr Expression to display + */ + void set_expression(Expression expr); + + /** + * @brief Trigger a blink + */ + void blink(); + + /** + * @brief Get current expression + * @return Current expression + */ + Expression get_expression() const { return current_expression_; } + + /** + * @brief Get preset expression state + * @param expr Expression preset + * @return Expression state configuration + */ + static ExpressionState get_preset_expression(Expression expr); + +protected: + void init(const Config &config); + void update_blink(float dt); + void update_pupils(float dt); + void update_expression(float dt); + void draw_eyes(); + void blend_expression_states(ExpressionState &result, const ExpressionState &from, + const ExpressionState &to, float t); + + float lerp(float a, float b, float t) { return a + (b - a) * t; } + + Config config_; + Expression source_expression_{Expression::NEUTRAL}; ///< Source expression for blending + Expression current_expression_{Expression::NEUTRAL}; + Expression target_expression_{Expression::NEUTRAL}; + ExpressionState current_state_; + ExpressionState target_state_; + float expression_blend_{0.0f}; + + // Eye state + float blink_state_{0.0f}; // 0=open, 1=closed + float blink_timer_{0.0f}; + float next_blink_time_{4.0f}; + bool is_blinking_{false}; + + // Look direction + float target_look_x_{0.0f}; + float target_look_y_{0.0f}; + float current_look_x_{0.0f}; + float current_look_y_{0.0f}; + + // Pupil physics + float pupil_velocity_x_{0.0f}; + float pupil_velocity_y_{0.0f}; + + // Redraw throttling + float redraw_timer_{0.0f}; +}; +} // namespace espp + +// for libfmt formatting of ExpressiveEyes::Expression +template <> struct fmt::formatter { + constexpr auto parse(format_parse_context &ctx) { return ctx.begin(); } + template + auto format(const espp::ExpressiveEyes::Expression &expr, FormatContext &ctx) const { + std::string name; + switch (expr) { + case espp::ExpressiveEyes::Expression::NEUTRAL: + name = "NEUTRAL"; + break; + case espp::ExpressiveEyes::Expression::HAPPY: + name = "HAPPY"; + break; + case espp::ExpressiveEyes::Expression::SAD: + name = "SAD"; + break; + case espp::ExpressiveEyes::Expression::ANGRY: + name = "ANGRY"; + break; + case espp::ExpressiveEyes::Expression::SURPRISED: + name = "SURPRISED"; + break; + case espp::ExpressiveEyes::Expression::SLEEPY: + name = "SLEEPY"; + break; + case espp::ExpressiveEyes::Expression::BORED: + name = "BORED"; + break; + case espp::ExpressiveEyes::Expression::WINK_LEFT: + name = "WINK_LEFT"; + break; + case espp::ExpressiveEyes::Expression::WINK_RIGHT: + name = "WINK_RIGHT"; + break; + default: + name = "UNKNOWN"; + break; + } + return fmt::format_to(ctx.out(), "{}", name); + } +}; diff --git a/components/expressive_eyes/src/expressive_eyes.cpp b/components/expressive_eyes/src/expressive_eyes.cpp new file mode 100644 index 000000000..536be08e0 --- /dev/null +++ b/components/expressive_eyes/src/expressive_eyes.cpp @@ -0,0 +1,440 @@ +#include "expressive_eyes.hpp" + +#include +#include + +using namespace espp; + +ExpressiveEyes::ExpressiveEyes(const Config &config) + : BaseComponent("ExpressiveEyes", config.log_level) { + init(config); +} + +void ExpressiveEyes::init(const Config &config) { + config_ = config; + + // Initialize with neutral expression + current_state_ = get_preset_expression(Expression::NEUTRAL); + target_state_ = current_state_; + + // Randomize initial blink time + static std::random_device rd; + static std::mt19937 gen(rd()); + std::uniform_real_distribution dist(2.0f, config_.blink_interval * 1.5f); + next_blink_time_ = dist(gen); + + logger_.info("Initialized: {}x{}, eye_size={}x{}", config_.screen_width, config_.screen_height, + config_.eye_width, config_.eye_height); +} + +void ExpressiveEyes::update(float dt) { + update_blink(dt); + update_pupils(dt); + update_expression(dt); + + // Limit redraw rate to avoid watchdog + static constexpr float MIN_REDRAW_INTERVAL = 0.033f; // ~30 FPS max + redraw_timer_ += dt; + + if (redraw_timer_ >= MIN_REDRAW_INTERVAL) { + redraw_timer_ = 0.0f; + draw_eyes(); + } +} + +void ExpressiveEyes::update_blink(float dt) { + if (config_.enable_auto_blink) { + blink_timer_ += dt; + + if (!is_blinking_ && blink_timer_ >= next_blink_time_) { + blink(); + } + } + + if (is_blinking_) { + blink_state_ += dt / config_.blink_duration; + + if (blink_state_ >= 2.0f) { + // Blink complete + blink_state_ = 0.0f; + is_blinking_ = false; + blink_timer_ = 0.0f; + + // Random next blink time + static std::random_device rd; + static std::mt19937 gen(rd()); + std::uniform_real_distribution dist(config_.blink_interval * 0.5f, + config_.blink_interval * 1.5f); + next_blink_time_ = dist(gen); + } + } +} + +void ExpressiveEyes::update_pupils(float dt) { + if (!current_state_.pupil.enabled) + return; + + if (config_.enable_pupil_physics) { + // Spring physics for smooth pupil movement with critical damping to prevent overshoot + const float spring_strength = 40.0f; // Much stronger for faster response + const float damping = 15.0f; // Higher damping to prevent bounce + + float dx = target_look_x_ - current_look_x_; + float dy = target_look_y_ - current_look_y_; + + // Stop motion if very close to target + float distance = std::sqrt(dx * dx + dy * dy); + if (distance < 0.005f) { // Tighter threshold + current_look_x_ = target_look_x_; + current_look_y_ = target_look_y_; + pupil_velocity_x_ = 0.0f; + pupil_velocity_y_ = 0.0f; + } else { + pupil_velocity_x_ += dx * spring_strength * dt - pupil_velocity_x_ * damping * dt; + pupil_velocity_y_ += dy * spring_strength * dt - pupil_velocity_y_ * damping * dt; + + current_look_x_ += pupil_velocity_x_ * dt; + current_look_y_ += pupil_velocity_y_ * dt; + + // Clamp to valid range + current_look_x_ = std::clamp(current_look_x_, -1.0f, 1.0f); + current_look_y_ = std::clamp(current_look_y_, -1.0f, 1.0f); + } + } else { + // Direct movement + current_look_x_ = target_look_x_; + current_look_y_ = target_look_y_; + } + + // Update pupil position in state + current_state_.pupil.x = current_look_x_; + current_state_.pupil.y = current_look_y_; +} + +void ExpressiveEyes::update_expression(float dt) { + if (current_expression_ != target_expression_) { + // Start new blend - save current blended state as source + source_expression_ = current_expression_; + target_state_ = get_preset_expression(target_expression_); + expression_blend_ = 0.0f; + current_expression_ = target_expression_; // Update tracking immediately + } + + // Blend towards target + if (expression_blend_ < 1.0f) { + expression_blend_ += dt * 8.0f; // Blend over ~0.125 seconds (fast, smooth transitions) + if (expression_blend_ > 1.0f) { + expression_blend_ = 1.0f; + } + + ExpressionState from = get_preset_expression(source_expression_); + // Save current pupil offset before blending + float current_pupil_x = current_state_.pupil.x; + float current_pupil_y = current_state_.pupil.y; + blend_expression_states(current_state_, from, target_state_, expression_blend_); + // Restore pupil offset (controlled by update_pupils, not expression blend) + current_state_.pupil.x = current_pupil_x; + current_state_.pupil.y = current_pupil_y; + } else { + // Blend complete, use target state but preserve pupil offset + float current_pupil_x = current_state_.pupil.x; + float current_pupil_y = current_state_.pupil.y; + current_state_ = target_state_; + current_state_.pupil.x = current_pupil_x; + current_state_.pupil.y = current_pupil_y; + } +} + +void ExpressiveEyes::blend_expression_states(ExpressionState &result, const ExpressionState &from, + const ExpressionState &to, float t) { + result.eye_width_scale = lerp(from.eye_width_scale, to.eye_width_scale, t); + result.eye_height_scale = lerp(from.eye_height_scale, to.eye_height_scale, t); + result.eye_rotation = lerp(from.eye_rotation, to.eye_rotation, t); + result.top_curve = lerp(from.top_curve, to.top_curve, t); + result.bottom_curve = lerp(from.bottom_curve, to.bottom_curve, t); + result.eye_offset_y = lerp(from.eye_offset_y, to.eye_offset_y, t); + + // Eyebrow blending - animate sliding in/out + if (from.eyebrow.enabled && to.eyebrow.enabled) { + // Both enabled - blend normally + result.eyebrow.enabled = true; + result.eyebrow.angle = lerp(from.eyebrow.angle, to.eyebrow.angle, t); + result.eyebrow.height = lerp(from.eyebrow.height, to.eyebrow.height, t); + result.eyebrow.thickness = lerp(from.eyebrow.thickness, to.eyebrow.thickness, t); + result.eyebrow.width = lerp(from.eyebrow.width, to.eyebrow.width, t); + } else if (!from.eyebrow.enabled && to.eyebrow.enabled) { + // Fading in - slide down from above + result.eyebrow.enabled = true; + result.eyebrow.angle = to.eyebrow.angle; + result.eyebrow.height = lerp(-1.2f, to.eyebrow.height, t); // Start above screen + result.eyebrow.thickness = to.eyebrow.thickness; + result.eyebrow.width = to.eyebrow.width; + } else if (from.eyebrow.enabled && !to.eyebrow.enabled) { + // Fading out - slide up above screen + result.eyebrow.enabled = (t < 1.0f); + result.eyebrow.angle = from.eyebrow.angle; + result.eyebrow.height = lerp(from.eyebrow.height, -1.2f, t); // Slide up + result.eyebrow.thickness = from.eyebrow.thickness; + result.eyebrow.width = from.eyebrow.width; + } else { + // Both disabled + result.eyebrow.enabled = false; + } + + // Cheek blending - animate sliding in/out + if (from.cheek.enabled && to.cheek.enabled) { + // Both enabled - blend normally + result.cheek.enabled = true; + result.cheek.size = lerp(from.cheek.size, to.cheek.size, t); + result.cheek_offset_y = lerp(from.cheek_offset_y, to.cheek_offset_y, t); + } else if (!from.cheek.enabled && to.cheek.enabled) { + // Fading in - slide up from below + result.cheek.enabled = true; + result.cheek.size = to.cheek.size; + result.cheek_offset_y = lerp(1.2f, to.cheek_offset_y, t); // Start below screen + } else if (from.cheek.enabled && !to.cheek.enabled) { + // Fading out - slide down below screen + result.cheek.enabled = (t < 1.0f); + result.cheek.size = from.cheek.size; + result.cheek_offset_y = lerp(from.cheek_offset_y, 1.2f, t); // Slide down + } else { + // Both disabled + result.cheek.enabled = false; + } + + // Keep pupil settings from target but use current look position + result.pupil = to.pupil; + result.pupil.x = current_look_x_; + result.pupil.y = current_look_y_; +} + +void ExpressiveEyes::draw_eyes() { + if (!config_.on_draw) + return; + + int center_x = config_.screen_width / 2; + int center_y = config_.screen_height / 2; + + int left_x = center_x - config_.eye_spacing / 2; + int right_x = center_x + config_.eye_spacing / 2; + + // Apply vertical offset from expression + int eye_y = center_y + static_cast(current_state_.eye_offset_y * config_.screen_height); + + // Apply eye position offset based on look direction + // Scale the offset to be smaller than pupil movement (about 15% of eye width/height) + int eye_offset_x = static_cast(current_look_x_ * config_.eye_width * 0.15f); + int eye_offset_y = static_cast(current_look_y_ * config_.eye_height * 0.15f); + + left_x += eye_offset_x; + right_x += eye_offset_x; + eye_y += eye_offset_y; + + // Calculate blink effect (0=open, 1=closed) + float blink_amount = 0.0f; + if (is_blinking_) { + if (blink_state_ <= 1.0f) { + // Closing (0 -> 1) + blink_amount = blink_state_; + } else { + // Opening (1 -> 0) + blink_amount = 2.0f - blink_state_; + } + } + + // Compute final eye dimensions + int eye_width = static_cast(config_.eye_width * current_state_.eye_width_scale); + int base_eye_height = static_cast(config_.eye_height * current_state_.eye_height_scale); + + // Handle wink expressions - blend between wink states + float target_left_blink = blink_amount; + float target_right_blink = blink_amount; + + if (target_expression_ == Expression::WINK_LEFT) { + target_left_blink = 0.9f; // Almost closed + target_right_blink = 0.0f; // Open + } else if (target_expression_ == Expression::WINK_RIGHT) { + target_left_blink = 0.0f; // Open + target_right_blink = 0.9f; // Almost closed + } + + // If blending between expressions, lerp the wink amounts + float left_blink = target_left_blink; + float right_blink = target_right_blink; + + if (expression_blend_ < 1.0f) { + float source_left_blink = blink_amount; + float source_right_blink = blink_amount; + + if (source_expression_ == Expression::WINK_LEFT) { + source_left_blink = 0.9f; + source_right_blink = 0.0f; + } else if (source_expression_ == Expression::WINK_RIGHT) { + source_left_blink = 0.0f; + source_right_blink = 0.9f; + } + + left_blink = lerp(source_left_blink, target_left_blink, expression_blend_); + right_blink = lerp(source_right_blink, target_right_blink, expression_blend_); + } + + int left_eye_height = static_cast(base_eye_height * (1.0f - left_blink)); + int right_eye_height = static_cast(base_eye_height * (1.0f - right_blink)); + + // Create state for each eye + EyeState left_eye; + left_eye.x = left_x; + left_eye.y = eye_y; + left_eye.width = eye_width; + left_eye.height = left_eye_height; + left_eye.expression = current_state_; + + EyeState right_eye; + right_eye.x = right_x; + right_eye.y = eye_y; + right_eye.width = eye_width; + right_eye.height = right_eye_height; + right_eye.expression = current_state_; + right_eye.expression.eye_rotation = -current_state_.eye_rotation; // Mirror rotation + + // Draw both eyes in single call + config_.on_draw(left_eye, right_eye); +} + +void ExpressiveEyes::look_at(float x, float y) { + target_look_x_ = std::clamp(x, -1.0f, 1.0f); + target_look_y_ = std::clamp(y, -1.0f, 1.0f); +} + +void ExpressiveEyes::set_expression(Expression expr) { + target_expression_ = expr; + logger_.debug("Expression changed to: {}", static_cast(expr)); +} + +void ExpressiveEyes::blink() { + if (!is_blinking_) { + is_blinking_ = true; + blink_state_ = 0.0f; + logger_.debug("Blinking"); + } +} + +ExpressiveEyes::ExpressionState ExpressiveEyes::get_preset_expression(Expression expr) { + ExpressionState state; + + // Default pupil - keep size constant across all expressions + state.pupil.enabled = true; + state.pupil.size = 0.3f; // Constant size + state.pupil.color = 0x0000; + + switch (expr) { + case Expression::NEUTRAL: + state.eye_width_scale = 1.0f; + state.eye_height_scale = 1.0f; + state.eye_rotation = 0.0f; + state.top_curve = 0.5f; + state.bottom_curve = 0.5f; + break; + + case Expression::HAPPY: + state.eye_width_scale = 1.0f; // Same size as normal + state.eye_height_scale = 1.0f; // Same size as normal + state.eye_rotation = 0.0f; + state.top_curve = 0.5f; + state.bottom_curve = 0.5f; + state.eye_offset_y = -0.08f; // Move eyes up slightly on face + state.cheek_offset_y = 0.18f; // Move cheeks much lower (positive is down) + // No eyebrows for happy (hidden by default) + state.cheek.enabled = true; // Shape bottom with cheeks + state.cheek.size = 0.8f; // Wider, more prominent cheeks + break; + + case Expression::SAD: + state.eye_width_scale = 0.9f; + state.eye_height_scale = 0.85f; // Slightly drooped + state.eye_rotation = -0.25f; // Downward curve + state.top_curve = 0.3f; // Flatter top + state.bottom_curve = 0.7f; // Rounder bottom + state.eyebrow.enabled = true; + state.eyebrow.angle = -0.436f; // Angled UP toward center (sad brows) - ~25 degrees in radians + state.eyebrow.height = -0.35f; + state.eyebrow.thickness = 0.1f; + state.eyebrow.width = 1.0f; + state.eyebrow.color = 0x0000; + state.cheek.enabled = true; // Shape bottom with cheeks + state.cheek.size = 0.6f; + state.cheek_offset_y = 0.15f; // Position below eye + break; + + case Expression::ANGRY: + state.eye_width_scale = 0.85f; // Narrower + state.eye_height_scale = 0.75f; + state.eye_rotation = 0.4f; // Sharp inward angle + state.top_curve = 0.2f; // Angular top + state.bottom_curve = 0.3f; + state.eyebrow.enabled = true; + state.eyebrow.angle = + 0.524f; // Angled DOWN toward center (angry furrowed brows) - ~30 degrees in radians + state.eyebrow.height = -0.15f; // Close to eyes + state.eyebrow.thickness = 0.12f; // Thicker + state.eyebrow.width = 0.9f; + state.eyebrow.color = 0x0000; + // Don't override pupil size + break; + + case Expression::SURPRISED: + state.eye_width_scale = 1.3f; // Wide open + state.eye_height_scale = 1.4f; + state.eye_rotation = 0.0f; + state.top_curve = 0.7f; + state.bottom_curve = 0.7f; + state.eyebrow.enabled = false; // No eyebrows for surprised + // Don't override pupil size + break; + + case Expression::SLEEPY: + state.eye_width_scale = 1.0f; + state.eye_height_scale = 0.35f; // Eyes mostly closed, droopy + state.eye_rotation = -0.1f; // Slight downward droop + state.top_curve = 0.3f; + state.bottom_curve = 0.3f; + state.pupil.enabled = true; // Keep pupils visible + state.eyebrow.enabled = true; + state.eyebrow.angle = 0.0f; // Straight across + state.eyebrow.height = -0.15f; // Lower, almost touching eyes + state.eyebrow.thickness = 0.18f; // Very thick, heavy looking + state.eyebrow.width = 1.0f; + state.eyebrow.color = 0x0000; + break; + + case Expression::BORED: + state.eye_width_scale = 1.0f; + state.eye_height_scale = 0.5f; // Half-closed + state.eye_rotation = 0.0f; + state.top_curve = 0.3f; + state.bottom_curve = 0.3f; + state.pupil.enabled = true; + // No eyebrows for bored - just half-closed neutral eyes + break; + + case Expression::WINK_LEFT: + // Base on neutral, blink applied per-eye in draw_eyes() + state.eye_width_scale = 1.0f; + state.eye_height_scale = 1.0f; + state.eye_rotation = 0.0f; + state.top_curve = 0.5f; + state.bottom_curve = 0.5f; + break; + + case Expression::WINK_RIGHT: + // Base on neutral, blink applied per-eye in draw_eyes() + state.eye_width_scale = 1.0f; + state.eye_height_scale = 1.0f; + state.eye_rotation = 0.0f; + state.top_curve = 0.5f; + state.bottom_curve = 0.5f; + break; + } + + return state; +} diff --git a/doc/Doxyfile b/doc/Doxyfile old mode 100644 new mode 100755 index 78d398bc3..6c00a5180 --- a/doc/Doxyfile +++ b/doc/Doxyfile @@ -96,6 +96,7 @@ EXAMPLE_PATH = \ $(PROJECT_PATH)/components/esp32-timer-cam/example/main/esp_timer_cam_example.cpp \ $(PROJECT_PATH)/components/esp-box/example/main/esp_box_example.cpp \ $(PROJECT_PATH)/components/event_manager/example/main/event_manager_example.cpp \ + $(PROJECT_PATH)/components/expressive_eyes/example/main/expressive_eyes_example.cpp \ $(PROJECT_PATH)/components/file_system/example/main/file_system_example.cpp \ $(PROJECT_PATH)/components/filters/example/main/filters_example.cpp \ $(PROJECT_PATH)/components/ftp/example/main/ftp_example.cpp \ @@ -220,6 +221,7 @@ INPUT = \ $(PROJECT_PATH)/components/esp32-timer-cam/include/esp32-timer-cam.hpp \ $(PROJECT_PATH)/components/esp-box/include/esp-box.hpp \ $(PROJECT_PATH)/components/event_manager/include/event_manager.hpp \ + $(PROJECT_PATH)/components/expressive_eyes/include/expressive_eyes.hpp \ $(PROJECT_PATH)/components/file_system/include/file_system.hpp \ $(PROJECT_PATH)/components/filters/include/biquad_filter.hpp \ $(PROJECT_PATH)/components/filters/include/butterworth_filter.hpp \ diff --git a/doc/en/display/expressive_eyes.rst b/doc/en/display/expressive_eyes.rst new file mode 100755 index 000000000..796028a8e --- /dev/null +++ b/doc/en/display/expressive_eyes.rst @@ -0,0 +1,39 @@ +Expressive Eyes APIs +******************** + +The `ExpressiveEyes` class provides an animated expressive eyes system for +displays using simple blob shapes. It supports multiple expressions, smooth +eye movement, blinking, and optional physics-based pupil movement. + +The component uses a callback-based drawing system, allowing you to implement +custom renderers for different display types and visual styles. Example drawer +implementations are provided showing both realistic eyes with pupils and +minimalist monochrome designs. + +Features include: + +- Multiple expressions (happy, sad, angry, surprised, neutral, sleepy, bored, wink_left, wink_right) +- Smooth eye movement with look_at positioning +- Automatic blinking with configurable intervals +- Optional pupils with physics-based movement +- Eyebrows and cheeks for enhanced expressions +- Smooth expression transitions with blending +- Customizable colors and sizes +- Frame-based animation system + +The drawing callback receives complete eye state information including +position, size, expression parameters, pupil position, eyebrow configuration, +and more, giving you full control over the rendering. + +.. ------------------------------- Example ------------------------------------- + +.. toctree:: + + expressive_eyes_example + +.. ---------------------------- API Reference ---------------------------------- + +API Reference +------------- + +.. include-build-file:: inc/expressive_eyes.inc diff --git a/doc/en/display/expressive_eyes_example.md b/doc/en/display/expressive_eyes_example.md new file mode 100755 index 000000000..afd9b8596 --- /dev/null +++ b/doc/en/display/expressive_eyes_example.md @@ -0,0 +1,2 @@ +```{include} ../../../components/expressive_eyes/example/README.md +``` diff --git a/doc/en/display/index.rst b/doc/en/display/index.rst old mode 100644 new mode 100755 index 919c1a8bd..80b9fbdb5 --- a/doc/en/display/index.rst +++ b/doc/en/display/index.rst @@ -6,6 +6,7 @@ Display APIs display display_drivers + expressive_eyes Code examples for the display API are provided in the `display_drivers` example folder..