2026 M-Design设计竞赛 - 用ESP32S3cam实现手势识别控制
该项目使用了ESP32S3cam,实现了手势识别控制的设计,它的主要功能为:便捷的非接触式的实现对电脑的控制以及对其他设备的控制。
标签
嵌入式系统
ESP32-S3
手势识别
ESP-DL
aumk
更新2026-06-11
11

基于ESP32S3手势联动控制系统


本项目基于 Seeed XIAO ESP32S3 Sense 与 ESP32-S3 矩阵灯板,在局域网内实现「板端手势识别 + PC 脚本联动 + RGB 状态反馈」的完整演示系统。


一、所选任务介绍


本项目选择 边缘 AI + 上位机联动方向:在资源受限的 ESP32-S3 上完成手部检测与 HaGRID 手势分类,Windows 电脑通过 HTTP 轮询获取稳定手势事件,并执行本地自动化脚本或驱动 16×16 WS2812 矩阵灯整屏变色。任务目标不是单纯跑通模型,而是形成可演示、可复现、可二次开发的「感知—决策—执行—反馈」闭环。



二、项目介绍与创意介绍


2.1 项目背景


在轻量智能家居场景中,手势控制是未来智能家居控制方式的一个重要分支。传统方案多依赖深度相机、专用驱动或常驻大型软件,部署成本高,隐私与延迟也难以兼顾。


本项目提出 「边缘识别 + 主机编排」思路:将手势 AI 推理放在带摄像头的 ESP32-S3 边缘设备上完成,Windows 电脑仅通过局域网 HTTP 轮询事件并执行脚本,结构清晰、易于二次开发。


2.2 创意亮点


端侧 AI:采用乐鑫 ESP-DL,在板端完成手部检测与 HaGRID 手势分类,减轻 PC 算力负担。

双模态手势:数字手势 1~5 映射系统快捷操作;like / ok / call 映射 RGB 矩阵灯色,形成「指令 + 状态反馈」闭环。

互斥防误触:矩阵灯板在线时仅响应 RGB 手势;灯板离线时仅响应 1~5,避免竖拇指被误判为「1」而误开程序。

可视化联调:本地网页灯板(8765 端口)实时显示哪条指令被触发,便于演示与调试。

可移动供电:后期增加锂电池充放一体模块与聚合物锂电池,便于脱离 USB 固定供电进行展台演示。


2.3 应用场景


追求新颖控制玩法和老年人行动不便的用户:对着摄像头做简单手势控制电脑(打开资源管理器、计算器、切换音频、最小化窗口等)。

展台/竞赛演示:手势触发 + 16×16 矩阵灯整屏变色,观感直观。

教学示例:展示 ESP-IDF、边缘 AI、WiFi IoT 与 Python 自动化综合应用。



三、软硬件介绍


3.1 核心硬件清单


视觉主控:Seeed XIAO ESP32S3 Sense(OV 摄像头 + ESP32-S3),负责板端手势识别与 HTTP 服务。

灯板主控:ESP32-S3-N16R8-DivKitC-1,WiFi 接收指令控制灯板显示状态。

RGB 矩阵灯板:16×16 WS2812(256 灯),整屏显示红/绿/蓝用于演示



3.2 后期扩展:供电模块

锂电池充放一体模块:支持 USB 充电与放电输出,为开发板/灯板提供 5V 或 3.3V 供电(按模块规格接线)。

聚合物锂电池:容量按演示时长选用(如 400mAh~2000mAh),提升设备便携性与展台无线化效果。


注意:

1.256 颗 WS2812 矩阵全亮时电流较大,矩阵板使用外部 5V 大电流电源;主控板可用锂电池模块供电,必要时共地。

2.为匹配电池性能,务必调整到合适的充电电流


3.3 其余软硬件

调试 PC:Windows11+ESP-IDF + arduino DIE+Python 3

外壳建模:SketchUp 2026

外壳打印:拓竹P2S


3.4 网络地址规划(默认)


相机板 XIAO S3:192.168.2.136

矩阵灯板 S3:192.168.2.138

PC:同网段 DHCP



四、方案框图


系统采用三端架构:相机板负责感知与事件上报,PC 负责编排与脚本执行,矩阵灯板负责远距离视觉反馈。三者经 2.4GHz 路由器组成局域网;可选锂电池模块为相机板或矩阵板提供便携供电。


4.1 设计思路


1. 感知在边缘:图像采集与推理均在 ESP32-S3 完成,PC 不处理视频流,降低带宽与隐私风险。

2. 事件驱动:板端 FSM 做「分数阈值 + 多帧投票 + 保持时间 + 冷却」,只有稳定手势才进入 pending 队列。

3. 主机编排:PC 轮询 `/api/gesture/poll` 取走事件,根据灯板是否在线决定走 RGB 或 1~5 通道。

4. 显示分离:矩阵灯板独立 IP,FastLED + 异步刷灯,HTTP 先应答再更新 LED,降低体感延迟。


4.2 实物连接


接线要点:

相机板:USB 或电池模块 5V 供电

矩阵板:GPIO21 → WS2812 DIN;矩阵 5V 接外部大电流电源;开发板 GND 与电源 GND 共地。

PC:与两板同网段,运行m.py。



五、软件流程图及关键代码


5.1 软件流程图



流程概要:


板端(.136):上电 → WiFi → 相机与 ESP-DL → 采图/检测/分类 → RGB 与 1~5 互斥 → FSM → 置位 pending / rgb_pending。

PC:启动 m.py → 探测矩阵在线 → 循环 poll → 灯板在线则 HTTP 变色。

矩阵(.138):s→ 收到 `/api/led` 立即 OK → LED 任务刷屏。


5.2 核心识别代码详解

(1)识别链路总览


板端识别在 Seeed XIAO ESP32S3 Sense 上完成,不依赖 PC 处理视频流。单帧处理流程如下:


相机采集帧(JPEG / RGB565)

转 RGB888,送入 ESP-DL

HandDetect检测手部框

对每个手部框 HandGestureCls裁剪分类(HaGRID 标签)

在 top-k 结果中分别统计 数字 1~5与 RGB 手势 like/ok/call

互斥决策:RGB 与数字二选一输出

两路FSM(多帧投票 + 保持时间 + 冷却)确认稳定手势

经 HTTP `/api/gesture/poll` 供 PC 轮询取走


涉及到的源文件说明:

gesture_infer.cpp / gesture_infer.h — 模型推理与互斥

gesture_fsm.cpp / gesture_fsm.h— 数字手势防抖

gesture_rgb_fsm.cpp / gesture_rgb_fsm.h — RGB 手势防抖

gesture_config.h — 阈值与帧率参数

app_main.cpp — 主循环串联

http_gesture.c — 事件上报 API


(2)识别结果数据结构


推理模块对外输出统一结构体,同时携带「原始 top-1 标签」与「业务有效字段」:


```c

typedef struct {

int gesture_id; /* 1-5,数字手势有效时 */

float score;

bool valid; /* true:one~five 且分数 ≥ 阈值 */

int rgb_color_id; /* 1=红(like) 2=绿(ok) 3=蓝(call) */

float rgb_score;

bool rgb_valid;

int hands; /* 本帧检测到的手数 */

char raw_label[20]; /* 分类器 top-1 标签,如 one、like */

float raw_score;

} gesture_infer_result_t;

```


说明:`raw_label` 用于调试与 `/api/status` 展示;真正触发 PC / 矩阵动作的是经 FSM 确认后的 `valid` 或 `rgb_valid` 事件。


(3)模型初始化


使用乐鑫 ESP-DL 组件:HandDetect + HandGestureCls(MobileNetV2 0.5 量化模型,HaGRID 数据集标签)。


```cpp

static HandDetect *s_hand_detect = nullptr;

static HandGestureCls *s_cls = nullptr;

static constexpr int kClsTopK = 5;



bool gesture_infer_init(void) {

s_hand_detect = new HandDetect();

s_cls = new HandGestureCls(HandGestureCls::MOBILENETV2_0_5_S8_V1, false);

if (!s_hand_detect || !s_cls) {

return false;

}

s_cls->set_topk(kClsTopK); /* 取 top-5,供互斥决策使用 */

return true;

}

```


要点:topk=5 是本项目防误触的关键——竖拇指(like)常被 top-1 判成 one,需要在 top-k 中单独比较 RGB 与数字分数。


(4)单帧推理流程


图像预处理,相机帧转为 RGB888,分配在 PSRAM,供 ESP-DL 使用:


```cpp

gesture_infer_result_t gesture_infer_from_fb(const void *fb_ptr) {

const camera_fb_t *fb = (const camera_fb_t *)fb_ptr;

/* JPEG 或 RGB565 → RGB888 */

fmt2rgb888(fb->buf, fb->len, fb->format, s_rgb888);



dl::image::img_t img = {

.data = s_rgb888,

.width = (uint16_t)w,

.height = (uint16_t)h,

.pix_type = dl::image::DL_IMAGE_PIX_TYPE_RGB888,

};

/* ... */

}

```


手部检测 + 手势分类,先检测手,再对每个手框裁剪分类,合并所有 top-k 结果:


```cpp

auto detections = s_hand_detect->run(img);

out.hands = (int)detections.size();

if (detections.empty()) {

strncpy(out.raw_label, "no_hand", sizeof(out.raw_label) - 1);

return out;

}



std::vector<dl::cls::result_t> results;

for (const auto &hand : detections) {

std::vector<int> crop_area = {hand.box[0], hand.box[1], hand.box[2], hand.box[3]};

auto res = s_cls->run_crop(img, crop_area);

results.insert(results.end(), res.begin(), res.end());

}

```


标签映射,HaGRID 类别名映射为业务 ID:


```cpp

/* 数字手势 → 1~5,对应 PC 脚本 1.py~5.py */

static int map_category(const char *name) {

if (strcmp(name, "one") == 0) return 1;

if (strcmp(name, "two") == 0) return 2;

if (strcmp(name, "three") == 0) return 3;

if (strcmp(name, "four") == 0) return 4;

if (strcmp(name, "five") == 0) return 5;

return 0;

}



/* RGB 手势 → 矩阵灯色 */

static int map_rgb_category(const char *name) {

if (strcmp(name, "like") == 0) return 1; /* red */

if (strcmp(name, "ok") == 0) return 2; /* green */

if (strcmp(name, "call") == 0) return 3; /* blue */

return 0;

}

```


(5)核心:top-k 互斥决策


在 top-k 候选中分别找 **最佳数字分** 与 **最佳 RGB 分**,再按规则二选一。这是解决「竖拇指被识别成 1」的核心逻辑:


```cpp

static constexpr float kRgbPriorityMargin = 0.10f;



float best_num_score = 0.0f;

int best_num_id = 0;

float best_rgb_score = 0.0f;

int best_rgb_id = 0;



for (const auto &r : results) {

const int gid = map_category(r.cat_name);

if (gid > 0 && r.score > best_num_score) {

best_num_score = r.score;

best_num_id = gid;

}

const int rid = map_rgb_category(r.cat_name);

if (rid > 0 && r.score > best_rgb_score) {

best_rgb_score = r.score;

best_rgb_id = rid;

}

}



const bool rgb_ok = best_rgb_id > 0 && best_rgb_score >= g_gesture_score_min;

const bool num_ok = best_num_id > 0 && best_num_score >= g_gesture_score_min;



/* like/ok/call 常与 one-five 混淆:RGB 分数接近时优先 RGB */

if (rgb_ok && (!num_ok || best_rgb_score + kRgbPriorityMargin >= best_num_score)) {

out.rgb_color_id = best_rgb_id;

out.rgb_score = best_rgb_score;

out.rgb_valid = true;

} else if (num_ok) {

out.gesture_id = best_num_id;

out.score = best_num_score;

out.valid = true;

}

```


规则说明


- 仅当 RGB 候选分数 ≥ `g_gesture_score_min`(默认 0.55)才认为 RGB 有效

- 若 RGB 分数 + 0.10 ≥ 数字分数,则 **优先 RGB**(即使 top-1 是 one)

- 否则输出数字 1~5

- 同一帧 **不会** 同时置位 `valid` 与 `rgb_valid`


(6)FSM 防抖:稳定才触发


单帧分类噪声大,需 **多帧投票 + 保持时间 + 冷却** 后才回调触发。


可调参数(gesture_config.h)


- `GESTURE_SCORE_MIN` = 0.55 — 分类置信度下限

- `GESTURE_VOTE_FRAMES` = 2 — 滑动窗口帧数

- `GESTURE_HOLD_MS` = 300 — 同一手势需持续毫秒数

- `GESTURE_COOLDOWN_MS` = 1000 — 触发后冷却,防连发

- `GESTURE_LOOP_DELAY_MS` = 40 — 主循环帧间隔


数字手势 FSM(gesture_fsm.cpp)


```cpp

void gesture_fsm_update(gesture_fsm_t *fsm, int gesture_id, float score, uint32_t now_ms) {

if (now_ms < fsm->cooldown_until_ms) {

return;

}

if (gesture_id < 1 || gesture_id > 5 || score < g_gesture_score_min) {

gesture_id = 0;

}

/* 写入环形投票缓冲 */

fsm->vote_buf[fsm->vote_pos] = gesture_id;

/* 多数票 ≥ 窗口一半 → stable */

int stable = majority_vote(fsm->vote_buf, fsm->vote_len);

if (stable == 0) {

fsm->current_id = 0;

return;

}

if (stable != fsm->current_id) {

fsm->current_id = stable;

fsm->tracking_since_ms = now_ms;

return;

}

/* 同一 stable 手势保持足够久 → 触发 */

if (now_ms - fsm->tracking_since_ms >= g_gesture_hold_ms) {

fsm->on_trigger(stable, score, held_ms, fsm->user);

fsm->cooldown_until_ms = now_ms + g_gesture_cooldown_ms;

gesture_fsm_reset(fsm);

}

}

```


RGB 手势 FSM(`gesture_rgb_fsm.cpp`)逻辑相同,只是投票范围为 color_id 1~3。


(7)主循环:推理 + 双 FSM


`app_main.cpp` 中独立 FreeRTOS 任务每帧执行推理,并分别更新数字 / RGB 两路 FSM:


```cpp

static void gesture_task(void *arg) {

while (true) {

camera_fb_t *fb = camera_xiao_capture();

gesture_infer_result_t r = gesture_infer_from_fb(fb);

camera_xiao_release(fb);



uint32_t now = (uint32_t)(esp_timer_get_time() / 1000);

http_gesture_set_status_frame(r.hands, r.raw_label, r.raw_score,

r.valid ? r.gesture_id : 0, r.valid ? r.score : 0.0f);



if (r.valid) {

gesture_fsm_update(&s_fsm, r.gesture_id, r.score, now);

} else {

gesture_fsm_update(&s_fsm, 0, 0.0f, now);

}

if (r.rgb_valid) {

gesture_rgb_fsm_update(&s_rgb_fsm, r.rgb_color_id, r.rgb_score, now);

} else {

gesture_rgb_fsm_update(&s_rgb_fsm, 0, 0.0f, now);

}

vTaskDelay(pdMS_TO_TICKS(GESTURE_LOOP_DELAY_MS));

}

}

```


触发回调将事件写入 HTTP 层:


```cpp

static void on_gesture_trigger(int n, float score, uint32_t held_ms, void *user) {

gesture_event_post(n, score, held_ms);

}



static void on_rgb_trigger(int color_id, float score, uint32_t held_ms, void *user) {

rgb_event_post(RGB_COLOR_NAME[color_id], RGB_GESTURE_NAME[color_id], score, held_ms);

}

```


(8)HTTP 事件接口(供 PC 轮询)


PC 端 `m.py` 周期性请求 `GET /api/gesture/poll`,取走 pending 事件并清零。


数字手势响应示例


```json

{"pending":true,"n":3,"score":0.712,"held_ms":320,"ts_ms":123456,"rgb_pending":false}

```



**RGB 手势响应示例**



```json

{"pending":false,"rgb_pending":true,"action":"rgb","color":"red","gesture":"like","rgb_score":0.681,"rgb_held_ms":305}

```



核心投递逻辑(http_gesture.c):



```c

void gesture_event_post(int n, float score, uint32_t held_ms) {

xSemaphoreTake(s_mux, portMAX_DELAY);

s_pending.n = n;

s_pending.score = score;

s_pending.held_ms = held_ms;

s_has_pending = true;

xSemaphoreGive(s_mux);

}



void rgb_event_post(const char *color, const char *gesture_label, float score, uint32_t held_ms) {

xSemaphoreTake(s_mux, portMAX_DELAY);

strncpy(s_rgb_color, color, sizeof(s_rgb_color) - 1);

strncpy(s_rgb_gesture, gesture_label, sizeof(s_rgb_gesture) - 1);

s_rgb_pending = true;

xSemaphoreGive(s_mux);

}

```





5.3 其余主要代码说明


(1)手势互斥与 top-k 分类 — `firmware/main/gesture_infer.cpp`

```cpp

const bool rgb_ok = best_rgb_id > 0 && best_rgb_score >= g_gesture_score_min;

const bool num_ok = best_num_id > 0 && best_num_score >= g_gesture_score_min;



/* like/ok/call 常与 one-five 混淆:top-k 中 RGB 分数接近时优先 RGB */

if (rgb_ok && (!num_ok || best_rgb_score + kRgbPriorityMargin >= best_num_score)) {

out.rgb_color_id = best_rgb_id;

out.rgb_score = best_rgb_score;

out.rgb_valid = true;

} else if (num_ok) {

out.gesture_id = best_num_id;

out.score = best_num_score;

out.valid = true;

}

```

使用 HandGestureCls 的 top-5 结果,分别在候选中找最佳 one~five 与 like/ok/call。当 RGB 分数接近数字手势时(差值 ≤ 0.10),优先判定为 RGB,减少竖拇指误判为「1」。


(2)HTTP 轮询与互斥规则 — `host/commands/m.py`


仅向探测在线的矩阵板发 HTTP,避免未接设备导致 2~3 秒 TCP 超时;灯板在线时禁用 1~5 脚本。


```python

def _rgb_http_dispatch_urls(configured_urls: list[str], rgb_serial: str | None) -> list[str]:

online = [u for u in _rgb_online_targets if u.startswith("http")]

if online:

return online

return []



def handle_poll_event(data: dict, rgb_urls: list[str], rgb_serial: str | None, *, rgb_enabled: bool) -> None:

rgb_online = rgb_enabled and bool(_rgb_board_online)

if rgb_online and (data.get("rgb_pending") or data.get("action") == "rgb"):

color = data.get("color", "")

if color:

_push_rgb_color(str(color), rgb_urls, rgb_serial)

return

if not rgb_online and data.get("pending"):

n = int(data.get("n", 0))

if 1 <= n <= 5:

run_gesture_script(n)

```


(3)矩阵低延迟 — `arduino-s3-rgb-matrix/arduino-s3-rgb-matrix.ino`


HTTP 处理与 FastLED.show() 分任务,先响应后刷灯;WiFi 不休眠降低延迟。


```cpp

static void handle_led() {

// ... 解析 color ...

post_color(cmd);

server.send(200, "text/plain", String("OK ") + color);

}



static void led_worker(void *arg) {

for (;;) {

if (xQueueReceive(led_queue, &cmd, portMAX_DELAY) == pdTRUE) {

apply_color(cmd); // FastLED.show() 在独立任务中执行

}

}

}



void setup() {

WiFi.setSleep(false);

// ...

}

```


(4)手势到 PC 动作映射


手势 1(one)→ 打开 D 盘(`1.py`)

手势 2(two)→ 打开计算器

手势 3(three)→ 切换音频设备

手势 4(four)→ 预留

手势 5(five)→ 最小化所有窗口

竖拇指(like)→ 矩阵红色

OK(ok)→ 矩阵绿色

打电话(call)→ 矩阵蓝色


、外壳制作

6.1 预留两个接口:调试接口 和 充电接口

6.2 采用角度可调的设计,方便识别

6.3 附件已上传模型文件stl.zip

七、实物演示与功能展示

7.1 演示前准备

(1)路由器上电,确认 PC、相机板(`.136`)、矩阵板(`.138`)同网段。

(2)相机板烧录 ESP-IDF 固件;矩阵板烧录 Arduino + FastLED 程序。

(3)PC 执行:m.py,终端应打印 RGB online 或 RGB offline。



7.2 相机画面与识别状态


打开 `http://192.168.2.136/view`可看到实时画面;访问 `/api/status` 可查看当前 raw_label、手部数量等 JSON 字段,用于确认模型正在运行。


7.3 模式 A:矩阵在线 — RGB 手势演示

当 m.py 探测到矩阵板在线时,系统自动关闭 1~5 数字手势,仅响应 like / ok / call。


7.4 模式 B:矩阵离线 — 识别数字手势 1~5

断开矩阵板电源或 WiFi,重启 m.py 后应显示 `RGB offline — gestures 1-5 enabled`。


7.5 小结

手势稳定后 PC/矩阵才动作 — 体现板端 FSM 防抖。

矩阵在线时竖拇指不会打开 D 盘 — 体现 top-k、RGB 优先与业务互斥。

矩阵变色无明显 2~3 秒卡顿— 体现仅在线板 HTTP 与异步刷灯。

网页灯板与执行动作一致 — 体现主机编排与可视化联调。


八、设计中遇到的难题与解决方法


难题:手势误识别为 1~5

原因:模型 top-1 常把 like 判成 one。

解决:top-5 分类 + RGB 优先边际;灯板在线时关闭 1~5。


难题:矩阵响应慢 2~3 秒

原因:PC 仍请求未接的旧 IP,TCP 超时。

解决:默认仅 `.138`;只向探测在线的板发 HTTP;缩短超时。


难题:WiFi 轮询超时

原因:浏览器占用 `/view` MJPEG 占带宽。

解决:异步 MJPEG;PC 增大 poll 超时并节流告警。


难题:C61 灯板无法烧录

原因:芯片需 IDF 5.5.1,Arduino 无预编译库。

解决:改用 S3-N16R8 + Arduino/FastLED 矩阵方案。


难题:256 灯刷新阻塞 HTTP

原因:NeoPixel 逐点写 + 同步 show。

解决:换 FastLED RMT;HTTP 先 OK 再后台刷灯。


难题:矩阵功耗与供电

原因:全亮电流大。

解决:限制亮度;5V 外电源;电池模块供主控。


九、竞赛心得体会

完整走通「边缘 AI + IoT + 上位机」链路,对 ESP-IDF 组件化、静态 IP、HTTP 事件驱动有了实践经验。

体会到系统级防误触(FSM + 业务互斥)与单模型多标签冲突需要联合处理,不能只调一个阈值。

硬件迭代(C61 → S3 矩阵 → 锂电池)说明方案要随可获得的板卡与库支持快速调整。

感谢电子森林和贸泽电子举办的这次2026 贸泽电子 M-Design 创意设计大赛(第二季)活动


十、源码


可编译源码位于本项目 `03-源代码` 目录,包含:


`firmware/` — ESP-IDF 相机板工程

`host/` — Python 轮询与网页灯板

`arduino-s3-rgb-matrix/` — 矩阵灯板 Arduino 工程

`tools/` — 烧录与 WiFi 配置脚本


编译与烧录步骤见同目录 `02-代码编译与使用说明.txt`。


附件下载
03-源代码.zip
stl.zip
团队介绍
个人爱好者
评论
0 / 100
查看更多
硬禾服务号
关注最新动态
0512-67862536
info@eetree.cn
江苏省苏州市苏州工业园区新平街388号腾飞创新园A2幢815室
苏州硬禾信息科技有限公司
Copyright © 2024 苏州硬禾信息科技有限公司 All Rights Reserved 苏ICP备19040198号