一、项目概况
使用Seeed XIAO ESP32-S3 Sense核心板运行Edge Impulse训练的手势识别模型,驱动OV2640摄像头,每间隔1秒进行手势图像的采集,而后根据推理,分类出有效手势,从而控制WS2812灯带的运行状态,蜂鸣器与OLED屏同步响应提示。
通过Edge Impulse Studio网页,使用电脑端的摄像头进行手势图像原始数据的在线采集,然后进行特征标记,数据预处理,模型设计、训练,输出模型的识别置信度,然后可在线使用电脑摄像头进行测试验证,结果比较理想则生成兼容Arduino IDE格式的数据包源码库。编写的工程源码中采用手势防抖机制,支持非阻塞控制,实现了使用“OK”手势打开灯带;“手掌”手势关闭灯带;“拳头”手势切换灯带点亮模式,暂且支持跑马灯、彩虹灯、呼吸灯三种模式循环切换,同时OLED屏实时显示当前状态信息,每手势识别成功会有蜂鸣器提示。
二、开发环境
工程编译IDE:Arduino IDE 2.3.6
ESP32系列核心BSP开发包:esp32 by espressif systems2.0.17-cn
Edge Impulse训练的手势识别模型:ei-gesturecontrol-arduino-1.0.2
开发环境的搭建,推荐入门指南网页:Seeed Studio XIAO ESP32S3 系列快速入门 ,由于使用到Edge Impulse Studio进行手势模型的创建,因此指定安装“esp32 by espressif systems2.0.17-cn”版本的核心BSP开发包。在Arduino的库管理中,根据项目需求,需要安装“Adafruit NeoPixel by Adafruit”库,用于驱动WS2812灯带;安装“U8g2”库,便于SSD1306显示中文字符。
三、硬件框图

四、软件执行流程图

五、模型训练介绍
关于该人工智能硬件实验套件,网页https://www.eetree.cn/platform/4731 有提供详细介绍,笔者参阅该网页下提供的实验3:《手势识别灯光控制》展开的线上模型训练,不同的是笔者使用的是电脑端的摄像头进行图像采样,而实验3介绍的是使用板上摄像头+microSD卡进行离线图像采集,然后导入Edge Impulse Studio网页数据采集入口。
接下来介绍下模型设计、训练流程。用户通过https://www.edgeimpulse.com/ 注册一个账号,然后登录进去,创建一个工程,选择“Collect new data”,通过连接电脑端摄像头进行图像的采集,每采集完一个手势动作则可在数据集网页上预览。本次笔者共采集了302个图像样本,分成5个手势动作类别,平均每个手势收集60个样本。然后对每张图片进行特征区域标记。

然后对原始数据集进行预处理,包括图像的尺寸、缩放模式、灰度级色深等参数的设置,生成符合预设的特征点。如果采集的图像特征比较易于区分,则每个手势图像特征点高度聚集。

然后对处理后的数据通过神经网络进行模型训练,训练出来的准确度通过矩阵框进行展现,如果置信度不高,建议重新采样图像或增加采样样本,重新特征区域标记。

训练出的模型识别准确性可通过网页上的验证入口进行在线测试,测试结果相对理想后,再选择Arduino库和量化(int8)模型,编译导出为兼容Arduino IDE数据库的ZIP文件。

六、工程源码
在实验3的示例上完善,将点亮LED更新成驱动WS2812灯带,导入上述训练出的手势模型文件。模型中支持5个手势动作的检测,工程实际上只用到3个,由于“调快”、“调慢”的处理使得灯带点亮变得不流畅,这里暂时就不添加这两个手势的逻辑处理代码。虽然使用了非阻塞方式去实时检测,但每隔1秒的时间扫描也会对灯带点亮造成一定的影响。如果使灯带点亮变得更流畅,后续再考虑使用RTOS框架。

1、包含的头文件
#include <Adafruit_NeoPixel.h>
#include <GestureControl_inferencing.h>
#include "edge-impulse-sdk/dsp/image/image.hpp"
#include "esp_camera.h"
#include <U8g2lib.h>
根据上述包含的头文件可知,需要安装相应的外设驱动库文件。
2、初始化设置
void setup()
{
Serial.begin(115200);
while (!Serial);
Serial.println("Edge Impulse GestureControl_inferencing Project");
// 初始化摄像头
if (ei_camera_init() == false) {
Serial.printf("Failed to initialize Camera!\r\n");
}
else {
Serial.printf("Camera initialized\r\n");
}
// 初始化LED灯带
strip.begin();
strip.setBrightness(0);
strip.show();
// 初始化蜂鸣器PWM
ledcSetup(4, 2000, 8);
ledcAttachPin(BUZZER_PIN, 4);
// 初始化OLED
u8g2.begin();
u8g2.setFont(u8g2_font_wqy12_t_gb2312);
u8g2.clearBuffer();
u8g2.drawUTF8(2, 20, "2026硬禾学堂开发板");
u8g2.drawUTF8(8, 40, "人工智能硬件实验");
u8g2.sendBuffer();
first_refresh_display = true;
Serial.printf("\nStarting continious inference in 5 seconds...\n");
ei_sleep(5000);
// 蜂鸣器开机提示
startBuzzer(32,100);
}
正如上面软件执行流程图,初始化函数对串口、摄像头、WS2812灯带、蜂鸣器、OLED屏进行初始化设置。
3、主循环逻辑处理
void loop()
{
handleBuzzer();
u8g2.setFont(u8g2_font_wqy12_t_gb2312);
u8g2.drawUTF8(2, 20, "2026寒假在家一起练");
u8g2.drawUTF8(6, 40, "智能手控氛围灯带");
// 灯带首次初始状态设置
if(first_refresh_display){
first_refresh_display = false;
currentMode = MODE_ALLLEDOFF;
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_wqy12_t_gb2312);
u8g2.drawUTF8(6, 60, "灯带初始关闭状态");
}
// 每1秒进行一次手势识别
if (millis() - lastModeSwitch > 1000) {
ei::signal_t signal;
signal.total_length = EI_CLASSIFIER_INPUT_WIDTH * EI_CLASSIFIER_INPUT_HEIGHT;
signal.get_data = &ei_camera_get_data;
if(ei_camera_capture((size_t)EI_CLASSIFIER_INPUT_WIDTH, (size_t)EI_CLASSIFIER_INPUT_HEIGHT, snapshot_buf) == false) {
Serial.printf("Failed to capture image\r\n");
return;
}
// 手势分类
ei_impulse_result_t result = {0};
EI_IMPULSE_ERROR err= run_classifier(&signal, &result, debug_nn);
if (err != EI_IMPULSE_OK) {
Serial.printf("ERR: Failed to run classifier (%d)\n", err);
return;
}
lastModeSwitch = millis();
// 处理识别到的手势
for(uint32_t i = 0; i < result.bounding_boxes_count; i++) {
ei_impulse_result_bounding_box_t bb = result.bounding_boxes[i];
if (bb.value == 0) continue;
Serial.printf("\n%s (%f) [ x: %u, y: %u, width: %u, height: %u]\n",bb.label,bb.value,bb.x,bb.y,bb.width,bb.height);
unsigned long now = millis();
// 防抖:如果当前手势与上次相同且未超过冷却时间,则忽略
if (lastGestureLabel == bb.label && (now - lastGestureTime < GestureCoolTime))continue;
// 更新上次手势信息
lastGestureLabel = bb.label;
lastGestureTime = now;
if (bb.label == "OK") {
startBuzzer(32,100);
strip.setBrightness(128);
strip.show();
currentMode = MODE_RUNNING; // 灯带打开,默认跑马灯模式
Serial.printf("OK detected -> Turn on\n");
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_wqy12_t_gb2312);
u8g2.drawUTF8(6, 60, "灯带打开");
} else if (bb.label == "Plam") {
startBuzzer(128,100);
currentMode = MODE_ALLLEDOFF; // 关闭灯带模式
Serial.printf("Plam detected -> Turn off\n");
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_wqy12_t_gb2312);
u8g2.drawUTF8(6, 60, "灯带关闭");
}else if (bb.label == "Fist") {
startBuzzer(64,180);
strip.setBrightness(128);
strip.show();
u8g2.clearBuffer();
u8g2.setFont(u8g2_font_wqy12_t_gb2312);
u8g2.drawUTF8(0, 60, "模式切换至 ->");
switchMode(); // 切换模式
Serial.printf("Fist detected -> Mode Change\n");
}
}
}
// 根据当前模式执行灯效
switch (currentMode) {
case MODE_RUNNING:
runningLightEffect();
break;
case MODE_RAINBOW:
rainbowEffect();
break;
case MODE_BREATH:
breathEffect();
break;
case MODE_ALLLEDOFF:
strip.setBrightness(0);
strip.clear();
strip.show();
break;
}
u8g2.sendBuffer();
}
主循环中,实现每间隔1秒,摄像头进行手势抓拍、检测,推理分类,进而根据相应分类处理,驱动灯带执行相应模式,代码中支持手势防抖与蜂鸣器非阻塞控制方式。手势冷却时间:GestureCoolTime参数值为1500,即1.5秒。
4、摄像头相关处理函数
/**
* @brief 启动摄像头开始流式传输
*
* @retval 返回false表示初始化失败
*/
bool ei_camera_init(void) {
if (is_initialised) return true;
#if defined(CAMERA_MODEL_ESP_EYE)
pinMode(13, INPUT_PULLUP);
pinMode(14, INPUT_PULLUP);
#endif
//初始化摄像头
esp_err_t err = esp_camera_init(&camera_config);
if (err != ESP_OK) {
Serial.printf("Camera init failed with error 0x%x\n", err);
return false;
}
sensor_t * s = esp_camera_sensor_get();
// 初始传感器被垂直翻转,颜色略显饱和
if (s->id.PID == OV3660_PID) {
s->set_vflip(s, 1); // 把它翻回去
s->set_brightness(s, 1); // 把亮度调高一点
s->set_saturation(s, 0); // 降低饱和度
}
#if defined(CAMERA_MODEL_M5STACK_WIDE)
s->set_vflip(s, 1);
s->set_hmirror(s, 1);
#elif defined(CAMERA_MODEL_ESP_EYE)
s->set_vflip(s, 1);
s->set_hmirror(s, 1);
s->set_awb_gain(s, 1);
#endif
is_initialised = true;
return true;
}
/**
* @brief 停止摄像头数据流
*/
void ei_camera_deinit(void) {
//释放摄像头资源
esp_err_t err = esp_camera_deinit();
if (err != ESP_OK)
{
Serial.printf("Camera deinit failed\n");
return;
}
is_initialised = false;
return;
}
/**
* @brief 捕捉、缩放和裁剪图像
*
* @param[in] img_width 输出图像宽度
* @param[in] img_height 输出图像高度
* @param[in] out_buf 用于存储输出图像的指针,可使用NULL
*
* @retval 如果未初始化、图像捕获、重新缩放或裁剪失败,则返回false
*
*/
bool ei_camera_capture(uint32_t img_width, uint32_t img_height, uint8_t *out_buf) {
bool do_resize = false;
if (!is_initialised) {
Serial.printf("ERR: Camera is not initialized\r\n");
return false;
}
camera_fb_t *fb= esp_camera_fb_get();
if (!fb) {
Serial.printf("Camera capture failed\n");
return false;
}
bool converted = fmt2rgb888(fb->buf, fb->len, PIXFORMAT_JPEG, snapshot_buf);
esp_camera_fb_return(fb);
if(!converted){
Serial.printf("Conversion failed\n");
return false;
}
if ((img_width != EI_CAMERA_RAW_FRAME_BUFFER_COLS) || (img_height != EI_CAMERA_RAW_FRAME_BUFFER_ROWS)) {
do_resize = true;
}
if (do_resize) {
ei::image::processing::crop_and_interpolate_rgb888(out_buf,EI_CAMERA_RAW_FRAME_BUFFER_COLS,EI_CAMERA_RAW_FRAME_BUFFER_ROWS,out_buf,img_width,img_height);
}
return true;
}
static int ei_camera_get_data(size_t offset, size_t length, float*out_ptr)
{
// 从RGB888缓冲区重新计算偏移量到像素索引
size_t pixel_ix = offset * 3;
size_t pixels_left = length;
size_t out_ptr_ix = 0;
while (pixels_left != 0) {
// 在此将BGR转换为RGB
// 参考 https://github.com/espressif/esp32-camera/issues/379
out_ptr[out_ptr_ix] = (snapshot_buf[pixel_ix + 2] << 16) + (snapshot_buf[pixel_ix + 1] << 8) + snapshot_buf[pixel_ix];
// 转到下一个像素
out_ptr_ix++;
pixel_ix+=3;
pixels_left--;
}
// 数据转换完成
return 0;
}
5、灯带的三种运行模式
/**
* 跑马灯:实现单颗LED依次亮起、移动的效果
*/
void runningLightEffect() {
static int position = 0; // 当前位置
static unsigned long lastUpdate = 0;
if (millis() - lastUpdate < SPEED_RUNNING) {
return; // 未到更新间隔
}
lastUpdate = millis();
// 设置当前位置的LED为红色
strip.setPixelColor(position, strip.Color(255, 0, 0)); // 红色
// 设置前一个位置的LED为淡红色(制造拖尾效果)
int prevPos = (position - 1 + LED_COUNT) % LED_COUNT;
strip.setPixelColor(prevPos, strip.Color(100, 0, 0)); // 暗红色
// 设置更前一个位置的LED为更淡的颜色
int prevPrevPos = (position - 2 + LED_COUNT) % LED_COUNT;
strip.setPixelColor(prevPrevPos, strip.Color(30, 0, 0)); // 更暗的红色
strip.show();
// 移动到下一个位置
position = (position + 1) % LED_COUNT;
}
/**
* 彩虹灯:所有LED呈现出流动的彩虹色效果
*/
void rainbowEffect() {
static unsigned long lastUpdate = 0;
static int hueOffset = 0; // 色调偏移量
if (millis() - lastUpdate < SPEED_RAINBOW) {
return;
}
lastUpdate = millis();
// 为每个LED计算颜色
for (int i = 0; i < LED_COUNT; i++) {
// 使用HSV色彩模型生成彩虹色
// 色调 = (LED索引 * 偏移量) 然后取模65536
int hue = (i * 65536 / LED_COUNT + hueOffset) % 65536;
// 将HSV转换为RGB并设置像素
// ColorHSV参数: 色调(0-65535), 饱和度(255), 亮度(255)
strip.setPixelColor(i, strip.gamma32(strip.ColorHSV(hue, 255, 255)));
}
strip.show();
// 增加偏移量,使彩虹产生流动效果
hueOffset = (hueOffset + 256) % 65536; // 步进值控制流动速度
}
/**
* 呼吸灯:LED亮度从暗到亮再到暗的平滑变化
* 所有LED同时变化,使用蓝色
*/
void breathEffect() {
static int brightness = 0; // 当前亮度 (0-255)
static int step = 5; // 亮度变化步长
static unsigned long lastUpdate = 0;
if (millis() - lastUpdate < SPEED_BREATH) {
return;
}
lastUpdate = millis();
// 更新亮度值
brightness += step;
// 到达边界时反转方向
if (brightness >= 255) {
brightness = 255;
step = -step;
} else if (brightness <= 0) {
brightness = 0;
step = -step;
}
// 设置所有LED为同一颜色,亮度变化
uint32_t color = strip.Color(0, 0, brightness); // 蓝色呼吸灯效
for (int i = 0; i < LED_COUNT; i++) {
strip.setPixelColor(i, color);
}
strip.show();
}
6、模式切换函数
/**
* 切换显示模式
*/
void switchMode() {
currentMode = (Mode)((currentMode + 1) % 3);
strip.clear();
strip.show();
Serial.print("Switch to Mode: ");
u8g2.setFont(u8g2_font_wqy12_t_gb2312);
switch (currentMode) {
case MODE_RUNNING:
Serial.printf("running mode\n");
u8g2.drawUTF8(80, 60, "跑马灯");
break;
case MODE_RAINBOW:
Serial.printf("rainbow mode\n");
u8g2.drawUTF8(80, 60, "彩虹灯");
break;
case MODE_BREATH:
Serial.printf("breathing mode\n");
u8g2.drawUTF8(80, 60, "呼吸灯");
break;
}
u8g2.sendBuffer();
}
7、蜂鸣器接口函数
/**
* @brief 启动蜂鸣
*
* @param[char] duty占空比值范围为0~255
* @param[int] duration_ms蜂鸣器鸣叫持续时间(毫秒)
*
*/
void startBuzzer(unsigned char duty, unsigned int duration_ms) {
// 如果蜂鸣器正在鸣响,可以选择忽略新请求或重新开始,这里采用重新开始
if (buzzerActive) {
ledcWrite(4, 0); // 先关闭当前蜂鸣
}
buzzerActive = true;
buzzerStartTime = millis();
buzzerDuration = duration_ms;
ledcWrite(4, duty);
}
// loop()中定期调用,检查蜂鸣时间是否结束
void handleBuzzer() {
if (buzzerActive && (millis() - buzzerStartTime >= buzzerDuration)) {
ledcWrite(4, 0);
buzzerActive = false;
}
}
七、效果展示
由于演示视频使用广角拍摄,OLED屏显示信息相较模糊,这里分享一下程序运行在相关状态下的屏显内容。演示视频时的串口打印信息见附件,可知置信度相对良好,当然也存在误识别的可能。





八、项目总结
通过此次“寒假在家一起练”活动,深入了解借助Edge Impulse Studio网页平台,采集音频、视频、传感设备的原始数据集方式,并对数据集进行预处理、以及模型的设计与训练流程。项目调试的心得体会汇总如下:
1、前期原本打算实现语音控制氛围灯带,然而第1次使用电脑端的麦克风采集音频样本,对音频数据分割处理,生成的模型在线上进行实测准确度灵敏,而导入到Arduino工程中,在开发板上进行麦克风音频识别测试,识别率极低。
2、后来又换成开发板上的麦克风,将音频样本存储在microSD卡中,再导入Edge Impulse网页中,生成的模型置信度并不高,受背景噪音,发音的高低,语速间隔快慢等因素的影响,生成的特征分布图,特征点都相较离散。
3、硬件方面,人工智能硬件实验套件外设模块相对较多,但Seeed Studio XIAO ESP32S3 Sense核心板接口有限,导致有几处管脚存在复用关系,因此不能同时使用,否则会造成严重干扰,影响所需外设接口的正常使用。
4、IDE方面,使用Arduino IDE编译工程源码,效率非常低。尤其后期加入“WiFi”功能接口后,如直接点击“上传”,IDE编译非常缓慢,动不动就卡死自动重启IDE,没有警告、错误提示,后面点击“编译”,工程能编译完成,但提示“Sketch uses 1144213 bytes (34%) of program storage space. Maximum is 3342336 bytes.Global variables use 288668 bytes (88%) of dynamic memory, leaving 39012 bytes for local variables. Maximum is 327680 bytes.”,再点击“上传”,程序能下载到开发板中,可开发板反复重启,无法正常运行,显然是内存不足导致。另外如果使用了Arduino IDE的串口监测,再次下载前没有手动关闭,则会下载失败,这是由于串口资源被占用,很容易被忽视,这是使用Arduino IDE开发所需要注意的。
5、尽管代码中使用了手势防抖、蜂鸣器的非阻塞控制,但是每隔1秒的方式去执行摄像头检测动作,在主循环中始终会影响灯带的实时连续点亮,因此灯带显示效果存在不流畅现象,后续待测试,增加RTOS框架优化灯带显示效果。
最后小结一下,本次活动受益匪浅,体验了时下非常火爆的AI设计流程,实现的手势识别灵敏度高,准确度良好。期待电子森林的下次“一起练”活动。