基于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`。