实现 ESP32-S3 上单摄像头人脸追踪的核心代码骨架,替代 Grove Vision AI V2
模块,通过 UART 发送人脸坐标驱动 RP2040 控制的眼球/YAW 舵机。
## 规划文档(docs/phase-01-face-tracking/)
- GOAL.md Phase 目标与 5 大成功标准
- RESEARCH.md esp-dl v3.2/3.3 + human_face_detect 0.4.1 技术调研
- PLAN.md 15 个原子任务的执行计划(T01-T15)
- PLAN_CHECK.md 计划审查报告(PASS_WITH_NOTES)
- PROGRESS.md 执行进度追踪(批次 1-3 已完成)
## 批次 1:依赖与开关(T01-T03)
- main/idf_component.yml
新增 esp-dl ~3.3.0 + human_face_detect 0.4.1(仅 S3/P4)
esp-sr 从 ~2.2.0 升级到 ~2.3.1,解决 esp-dsp 1.6/1.7 版本冲突
- main/Kconfig.projbuild
新增 CONFIG_XIAOZHI_ENABLE_FACE_TRACKING 开关(默认 y,depends on S3)
新增 CONFIG_XIAOZHI_FACE_TRACKING_FPS_CHOICE(5/10/15)
- main/boards/common/esp32_camera.{h,cc}
新增 ProbeFrameCapture() 最小 V4L2 DQBUF/QBUF 探针(T01)
- main/application.cc
Start() 末尾调用 probe 验证摄像头硬件链路
## 批次 2:人脸检测核心(T04-T06)
- main/boards/common/esp32_camera.{h,cc}
新增 FrameRef 结构体 + CaptureForDetection/ReleaseDetectionFrame
双超时 mutex 策略:face_tracker 10ms timeout 跳帧,Capture() RAII guard
- main/face_tracker.{h,cc}(新建)
Core 0 / 优先级 2 / 栈 8KB 独立任务
集成 esp-dl HumanFaceDetect 推理
坐标归一化 cx*224/W-112,匹配 RP2040 pixel_centre=112
多人脸遍历挑 score 最高,避免多脸时眼球摇摆
三重保护:Kconfig depends on S3 + 源文件 #if 守卫 + CMake 条件排除
- main/CMakeLists.txt
非 S3 目标从 SOURCES 移除 face_tracker.cc
## 批次 3:UART 协议扩展(T07)
- main/uart_component.{h,cc}
新增 uart_send_face(x,y) 发送 face:x,y\r\n 协议
extern "C" 链接名配合 face_tracker 的弱符号声明
全局 TX mutex 保护所有 UART 写入,防并发帧交织
uart_send_string 同步加锁保持一致性
## 编译验证
idf.py build 通过,固件 2.51MB / 剩余 1.46MB (36% free)
当前 face_tracker 未被 application 激活(留到 T11),
UART/摄像头现有功能零影响。
## 未完成(下次继续)
- T01 硬件 probe 实机验证
- T08-T10 RP2040 端 parse_face + facetrack 双数据源改造
- T11-T15 application 接入 + 端到端联调 + 性能调优 + 最终验收
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
36 KiB
Phase 1: 单摄像头人脸追踪 — 技术调研
调研日期: 2026-04-17 研究对象: ESP32-S3-N16R8 上的摄像头 + 人脸检测 + UART 坐标协议 总体置信度: HIGH(关键组件均有官方验证),少数性能/内存数字为 MEDIUM(社区数据) ESP-IDF 版本: 5.4.2(已在 dependencies.lock 中锁定)
摘要
本 Phase 的核心技术决策是在不破坏现有 xiaozhi-esp32 语音对话架构的前提下,新增一个人脸检测任务:
- 推理库选型:esp-dl v3.2.0 + human_face_detect v0.4.1(官方组件,2024-10 发布,而 esp-who 已 refactor 为 esp-dl 的外壳,不再推荐直接使用)。
- 模型选型:两阶段 MSR_S8_V1 + MNP_S8_V1(小模型 + 高精度级联,ESP32-S3 总推理耗时 ~38ms,FPS 上限约 26)。
- 图像格式:
Esp32Camera已选 YUYV 为 OV3660 的首选格式,esp-dl 原生支持DL_IMAGE_PIX_TYPE_YUYV以及RGB565LE,可直接喂给模型(内部自动 resize + 归一化),零拷贝。 - UART 协议:在现有
uart_send_string()的基础上扩展,新增face:x,y\n协议(与现有状态字符串字典集隔离,零侵入)。 - 任务调度:人脸检测任务 pinnedToCore=0(与 main_event_loop 同 Core),priority=2(低于音频 I/O,高于空闲),栈 8KB。音频任务继续在 Core 0 priority=8 抢占。
- 分区方案:占用现有 8MB assets SPIFFS 的 ~200KB 用于
human_face_det子分区 OR 将模型编入 flash rodata(二选一,推荐后者简化部署)。 - Kconfig 开关:新增
CONFIG_XIAOZHI_ENABLE_FACE_TRACKING,默认y,方便未来回退。
主要建议: 直接把模型以 flash rodata 方式嵌入(no partition change),采集 QVGA(320×240)YUYV 帧,交给 HumanFaceDetect::run(),取首个结果的 bbox 中心坐标映射到 [-112, +112] 范围(匹配 RP2040 的 pixel_centre=112),通过 UART1 发送 face:x,y\n,≥ 5 FPS(实测上限 15-20 FPS,主动限频到 10 FPS 以降低 CPU 压力)。
Standard Stack
Core(官方组件,HIGH 置信度)
| 库 | 版本 | 用途 | 选择理由 |
|---|---|---|---|
espressif/esp-dl |
3.2.0(2024-10-23 发布) | 神经网络推理引擎 | 官方 AI 库;ESP-IDF 5.3+ 支持;Conv2D 自动双核调度 |
espressif/human_face_detect |
0.4.1 | MSR+MNP 人脸检测封装 | 官方标准实现;模型、预处理、后处理一体封装 |
espressif/esp_video |
1.3.1(已有) | OV3660 V4L2 驱动 | 现有组件,无需改动 |
安装命令:
# 在 main/idf_component.yml 添加:
# espressif/esp-dl: ^3.2.0
# espressif/human_face_detect: ^0.4.1
idf.py reconfigure
版本验证: 2026-04-17 通过 components.espressif.com 页面确认 v3.2.0 是 esp-dl 最新稳定版,v0.4.1 是 human_face_detect 最新版。
Supporting(已存在于项目,无需新增)
| 库 | 版本 | 用途 |
|---|---|---|
espressif/dl_fft |
≥0.3.1 | esp-dl 间接依赖(自动拉取) |
espressif/esp-dsp |
==1.7.0 | esp-dl 间接依赖(自动拉取) |
espressif/esp_new_jpeg |
^0.6.1 | 已有,esp-dl 间接依赖 |
ESP-IDF driver/uart.h |
5.4.2 内置 | UART1 发送坐标到 RP2040 |
Alternatives Considered
| 替代方案 | 是否可行 | 为什么不选 |
|---|---|---|
espressif/esp-who |
可行但过时 | README 声明已 refactor 为 esp-dl 的 example wrapper;其 legacy release/v1.1.0 分支不再维护 ESP32-S3 |
| TensorFlow Lite Micro + MTCNN (mauriciobarroso/mtcnn_esp32s3) | 可行 | 缺少官方支持;推理速度慢(报告 < 5 FPS) |
| 手写 SSD-MobileNet | 不推荐 | 训练/量化工具链复杂;esp-dl 已有现成模型 |
| Edge Impulse FOMO | 可行但付费 | 商用授权;与小智主线集成阻力大 |
Architecture Patterns
推荐文件结构
main/
├── uart_component.{h,cc} # 扩展新增 uart_send_face()
├── face_tracker.{h,cc} # 【新增】人脸检测任务封装
├── boards/
│ └── common/
│ └── esp32_camera.{h,cc} # 扩展新增 GetFrame() 接口
├── application.cc # 启动 face_tracker,状态机集成
└── Kconfig.projbuild # 新增 CONFIG_XIAOZHI_ENABLE_FACE_TRACKING
Pattern 1: 直接复用现有帧缓冲(不新增 FrameBuffer)
要点: Esp32Camera::Capture() 已经将帧拷贝到 frame_.data(PSRAM),但 frame_ 是 private。Phase 需要在 Esp32Camera 里新增一个公开的"取当前帧引用"方法,而不是为检测任务复制整帧。
// esp32_camera.h 新增(不影响现有 Capture 流)
struct FrameRef {
const uint8_t* data;
size_t len;
uint16_t width, height;
v4l2_pix_fmt_t format;
};
virtual bool CaptureForDetection(FrameRef* out); // 不做 JPEG 编码、不做预览显示
// 实现:只做 VIDIOC_DQBUF → memcpy 到 detection_frame_ → VIDIOC_QBUF
// 与 Capture() 共享一个 video_fd_,用 mutex 互斥
源码参考: 现有 Esp32Camera::Capture() L386-839 是完整的采集+旋转+显示流程,检测任务只需采集的前半段。
Pattern 2: 独立 FreeRTOS 任务 + 主动限频
// face_tracker.cc
static void FaceTrackerTask(void* arg) {
const TickType_t period = pdMS_TO_TICKS(100); // 10 FPS
TickType_t last_wake = xTaskGetTickCount();
HumanFaceDetect* detector = new HumanFaceDetect(); // ~40ms 初始化
FrameRef frame;
while (!stop_requested_) {
vTaskDelayUntil(&last_wake, period);
auto cam = (Esp32Camera*)Board::GetInstance().GetCamera();
if (!cam || !cam->CaptureForDetection(&frame)) continue;
dl::image::img_t img = {
.data = (void*)frame.data,
.width = frame.width,
.height = frame.height,
.pix_type = dl::image::DL_IMAGE_PIX_TYPE_YUYV,
};
auto& results = detector->run(img);
if (results.empty()) {
// 3 秒无脸后不再发送,RP2040 端会自动 grove_active=False
continue;
}
const auto& r = results.front();
int cx = (r.box[0] + r.box[2]) / 2;
int cy = (r.box[1] + r.box[3]) / 2;
// 映射到 [-112, +112] 区间(匹配 RP2040 pixel_centre=112)
int x_offset = cx * 224 / frame.width - 112;
int y_offset = cy * 224 / frame.height - 112;
char buf[32];
snprintf(buf, sizeof(buf), "face:%d,%d", x_offset, y_offset);
uart_send_string(buf);
}
delete detector;
vTaskDelete(NULL);
}
Pattern 3: UART 协议前缀隔离
要点: RP2040 的 main.py L125-131 处理 incoming_commands,先查 action_map(act_* 开头),再查 state_map(idle/speaking/listening/...)。任何以 face: 开头的字符串既不在 action_map,也不在 state_map,所以现有代码会直接忽略——零侵入。Phase 只需在 RP2040 端 coms.py 或 main.py 新增对 face: 前缀的识别并注入 facetrack() 数据流。
# RP2040 端 coms.py 新增方法(不修改 esp_read 接口)
def parse_face(self, line):
"""line 形如 'face:50,-30'"""
if not line.startswith('face:'):
return None
try:
parts = line[5:].split(',')
x = int(parts[0])
y = int(parts[1])
return (x, y)
except (ValueError, IndexError):
return None
# main.py 在 for data in incoming_commands 循环里:
offset = external.parse_face(data)
if offset:
animation.grove_active = True
animation.grove_last_seen = time.ticks_ms()
# 直接塞入 facetrack 的 eyl/eyr/pit 更新逻辑(复用已有代码)
...
elif data in animation.action_map: ...
elif data in animation.state_map: ...
Anti-Patterns to Avoid
- 不要在 LVGL 任务或 audio 任务里做推理:38ms 的 Conv2D 会导致 Opus 解码卡顿、GIF 掉帧。必须独立任务。
- 不要用
xTaskCreatePinnedToCore(..., 1)(Core 1):Core 1 通常被 WiFi/BT/Audio I/O 占用,新增 CPU-bound 任务会恶化音频延迟。Core 0 与主循环/LVGL 共享更合适(ISR 少,可被音频任务抢占)。 - 不要改
Esp32Camera::Capture()签名:MCP camera tool(mcp_server.cc:100)仍在用,保持稳定。新增独立方法。 - 不要用默认 10 FPS 以上采样率:帧率越高,推理越频繁,和音频争抢 Core 0 越厉害。实测可先从 5 FPS 起步,视音频表现再调。
- 不要在 UART 发送坐标时加
\r\n前缀:uart_send_string()自动加\r\n,RP2040 按\n分割。
Don't Hand-Roll
| 问题 | 不要自己做的 | 改用 | 原因 |
|---|---|---|---|
| 人脸检测推理 | 手写 MTCNN/MobileNet 前向传播 | HumanFaceDetect |
量化、SIMD 优化、ESP32-S3 vector 指令用法复杂 |
| YUYV→RGB888 转换 | 手写 CbCr 重采样 | dl::image 内置 yuv2rgb565 / yuv2rgb888 |
已有 C++ SIMD 优化,比软件循环快 5x |
| 图像 resize | 手写双线性插值 | dl::image::ImagePreprocessor(HumanFaceDetect::run() 内部自动调用) |
已做 resize + 归一化 + 量化 |
| 帧缓冲分配 | 自己 heap_caps_malloc(MALLOC_CAP_SPIRAM) |
复用 Esp32Camera::frame_(已在 PSRAM) |
避免 PSRAM 双份占用(每帧 QVGA RGB565 = 150KB) |
| UART 缓冲/发送 | 自己封装一层 TX queue | 直接调 uart_send_string() |
已有简洁 API,协议扩展走前缀隔离 |
| 模型格式解析 | 自己读 .espdl | HumanFaceDetect 构造函数自动加载 |
FlatBuffers + zero-copy 不可见,绝对不能动 |
核心洞察: esp-dl 的设计哲学是"提供 Model + ImagePreprocessor + Detect 三件套",开发者只管构造 img_t 和消费 result_t。Phase 代码应保持极简(< 200 行)。
Runtime State Inventory
| 类别 | 发现的项目 | 所需动作 |
|---|---|---|
| 存储数据 | 无 — 不涉及 NVS、SPIFFS 已有数据结构;也不需要持久化坐标 | 无 |
| 活跃服务配置 | 无 — 不涉及 n8n/Datadog/Cloudflare 等外部服务 | 无 |
| OS 级注册状态 | 无 — ESP32-S3 无 systemd/Task Scheduler;FreeRTOS 任务完全 runtime 创建 | 无 |
| Secrets/环境变量 | 无 — 不需要新 API key | 无 |
| 构建产物/已装包 | managed_components/ 新增 espressif__esp-dl(~10MB 源码)+ espressif__human_face_detect |
idf.py reconfigure 自动拉取,首次构建耗时 +2-3 分钟 |
| 分区表 / Flash | 若选方案 A(rodata)无改动;方案 B(partition)需改 partitions/v2/16m.csv 新增 200KB 分区 |
若选 B:从 assets 的 8MB 里切 200KB,或在 ota_1 和 assets 之间插入(需整体调整偏移) |
说明: managed_components 目录是 ESP-IDF 构建系统自动管理的,Clean build 会重新拉取。加入 dependencies.lock(git 跟踪)即可复现。
Common Pitfalls
Pitfall 1: OV3660 在 xiaozhi 项目中的已知崩溃
现象: GitHub issue #1588 报告 compact-wifi-s3cam + OV3660 调用 MCP camera tool 时冻结,OV2640 正常。
根因: OV3660 的 DMA/FIFO 配置与 OV2640 不同,初始化参数需要分别处理。本项目已经完成 3 根飞线(GPIO 35→14, 36→41, 37→42),应在同一硬件上 pre-test Capture() 是否正常工作(独立验证)。
缓解: Phase 开工前先在 main.cc 里插入一段临时代码,调用 Board::GetInstance().GetCamera()->Capture() 一次并观察是否成功;若失败,先修驱动再做检测。
Pitfall 2: 采集与检测共用 video_fd_ 的竞态
现象: MCP take_photo 工具和 face_tracker 同时调用 ioctl(video_fd_, VIDIOC_DQBUF, ...) 会争抢帧缓冲(req.count=1 for DVP)。
根因: DVP 只申请 1 个 V4L2 缓冲区,同时有两个消费者会导致 ENOBUFS 或帧乱序。
缓解: 在 Esp32Camera 里加一个 std::mutex capture_mutex_,Capture() 和 CaptureForDetection() 都在进入时加锁;face_tracker 在 take_photo 进行时跳过一帧(检测 mutex trylock 失败则 continue)。
Pitfall 3: esp-dl 推理导致音频任务延迟
现象: 38ms 的 Conv2D 会阻塞 Core 0,Opus 解码每 20ms 一次的缓冲送入会延迟,出现"吱吱"卡顿。 根因: esp-dl 的 Conv2D 支持双核调度,但 task 本身 pin 在 Core 0 时,只能借一部分时间片到 Core 1。 缓解方案:
- face_tracker 任务优先级 = 2(低于 audio_output=4、audio_input=8、main_event_loop=3)
- 每次推理后主动
vTaskDelay(0)yield 一次 - 使用
param_copy=false(模型权重留 flash,不占 PSRAM,但推理变慢 20-30%)仅作为降级选项
警示信号: 日志看到 audio_output task: queue full 或 Opus 解码间隔超过 25ms。
Pitfall 4: PSRAM 分配失败
现象: 启动时 HumanFaceDetect 构造函数返回 NULL 或 abort,因为 8MB PSRAM 已经被 LVGL 帧缓冲、JPEG 编码器、frame_ 占满。
根因: esp-dl 默认用 MALLOC_CAP_SPIRAM 分配模型权重(~200KB)和中间 tensor buffer(~300KB),总共约 500KB PSRAM。
缓解:
- 构造时检查:
detector = new(std::nothrow) HumanFaceDetect(); if (!detector) { ESP_LOGE(...); return; } - PSRAM 预算在当前项目中:LVGL ~150KB + 摄像头帧(QVGA YUYV=150KB) + Opus buffer ~50KB + 余量 ~7.5MB,足够。
- 触发 OOM 时,降级为
ESPDET_PICO_224_224_FACE(单阶段模型,内存更少但 122ms 延迟,FPS 降至 8)。
Pitfall 5: RP2040 端 UART1 已有消费者
现象: 新增 face:x,y 协议后 RP2040 端解析错乱。
根因: RP2040 main.py:124 的 external.esp_read() 已经消费 UART1,返回 commands 列表。任何新协议必须进入同一分发链。
缓解: 见上文 Pattern 3,将 face: 前缀的解析插入 for data in incoming_commands 循环的最前面。
Pitfall 6: UART1 波特率不足导致丢包
现象: 10 FPS 持续发 face:50,-30\n(约 14 bytes/帧)理论带宽 = 140 bytes/s,115200 bps 绰绰有余(~11 kB/s)。但如果同时夹杂 speaking、listening 等状态字符串高频切换,可能在同一 tick 内发送多条。
缓解: 保持 115200,不升高;uart_write_bytes 同步阻塞即可。
Pitfall 7: RP2040 的 facetrack() 数据格式不匹配
现象: Grove Vision AI V2 的输出是 boxes:[224,224,100,100,0],即 [x_center, y_center, w, h, score],除以 224 后减 pixel_centre=112 得到 offset;但 esp-dl 输出的是 bbox 左上+右下坐标 [x1, y1, x2, y2],单位为图像原始像素(如 320×240)。
缓解: ESP32 端在发送前就完成"归一化到 224×224"的映射:
int cx = (r.box[0] + r.box[2]) / 2;
int cy = (r.box[1] + r.box[3]) / 2;
int x_offset = cx * 224 / frame.width - 112; // -112 ~ +112
int y_offset = cy * 224 / frame.height - 112;
这样 RP2040 端无须修改 pixel_centre=112、deadzone=20、x_adj_factor=10 等参数,即可与原 Grove 协议行为一致。
Code Examples
官方 human_face_detect example(来源:esp-dl master)
// 来源: https://github.com/espressif/esp-dl/blob/master/examples/human_face_detect/main/app_main.cpp
#include "dl_image_jpeg.hpp"
#include "human_face_detect.hpp"
extern "C" void app_main(void) {
dl::image::jpeg_img_t jpeg_img = {.data = (void *)human_face_jpg_start,
.data_len = (size_t)(human_face_jpg_end - human_face_jpg_start)};
auto img = dl::image::sw_decode_jpeg(jpeg_img, dl::image::DL_IMAGE_PIX_TYPE_RGB888);
HumanFaceDetect *detect = new HumanFaceDetect();
auto &detect_results = detect->run(img);
for (const auto &res : detect_results) {
ESP_LOGI(TAG, "[score: %f, x1: %d, y1: %d, x2: %d, y2: %d]",
res.score, res.box[0], res.box[1], res.box[2], res.box[3]);
}
delete detect;
heap_caps_free(img.data);
}
本项目适配版(建议模板)
// face_tracker.cc(新增)
#include "face_tracker.h"
#include "board.h"
#include "esp32_camera.h"
#include "uart_component.h"
#include "human_face_detect.hpp"
#include "dl_image_define.hpp"
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#define TAG "FaceTracker"
static TaskHandle_t s_task = nullptr;
static volatile bool s_stop = false;
static void face_tracker_task(void* arg) {
vTaskDelay(pdMS_TO_TICKS(500)); // 等待摄像头 ISP 预热
HumanFaceDetect* detector = new(std::nothrow) HumanFaceDetect();
if (!detector) {
ESP_LOGE(TAG, "HumanFaceDetect init failed (OOM?)");
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "人脸检测任务启动,采样间隔 100ms");
const TickType_t period = pdMS_TO_TICKS(100); // 10 FPS
TickType_t last_wake = xTaskGetTickCount();
int no_face_counter = 0;
while (!s_stop) {
vTaskDelayUntil(&last_wake, period);
auto cam = dynamic_cast<Esp32Camera*>(Board::GetInstance().GetCamera());
if (!cam) continue;
Esp32Camera::FrameRef frame; // 新 API
if (!cam->CaptureForDetection(&frame)) continue;
dl::image::img_t img = {
.data = (void*)frame.data,
.width = frame.width,
.height = frame.height,
.pix_type = dl::image::DL_IMAGE_PIX_TYPE_YUYV,
};
auto& results = detector->run(img);
if (results.empty()) {
no_face_counter++;
continue;
}
no_face_counter = 0;
const auto& r = results.front();
int cx = (r.box[0] + r.box[2]) / 2;
int cy = (r.box[1] + r.box[3]) / 2;
int x_offset = cx * 224 / frame.width - 112;
int y_offset = cy * 224 / frame.height - 112;
char buf[32];
snprintf(buf, sizeof(buf), "face:%d,%d", x_offset, y_offset);
uart_send_string(buf);
ESP_LOGD(TAG, "face detected: score=%.2f, offset=(%d,%d)", r.score, x_offset, y_offset);
}
delete detector;
vTaskDelete(NULL);
}
void face_tracker_start(void) {
if (s_task) return;
s_stop = false;
xTaskCreatePinnedToCore(face_tracker_task, "face_track",
8 * 1024, NULL, 2, &s_task, 0);
}
void face_tracker_stop(void) {
if (!s_task) return;
s_stop = true;
// task 自己 vTaskDelete,不需要外部 join
s_task = nullptr;
}
扩展 Esp32Camera 新增 CaptureForDetection
// esp32_camera.h 新增
struct FrameRef {
const uint8_t* data;
size_t len;
uint16_t width, height;
v4l2_pix_fmt_t format;
};
virtual bool CaptureForDetection(FrameRef* out);
// esp32_camera.cc 新增实现(简化版)
bool Esp32Camera::CaptureForDetection(FrameRef* out) {
if (!streaming_on_ || video_fd_ < 0) return false;
std::lock_guard<std::mutex> lock(capture_mutex_); // 新增成员
struct v4l2_buffer buf = {};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
if (ioctl(video_fd_, VIDIOC_DQBUF, &buf) != 0) return false;
// 不拷贝、不旋转、不显示——直接给 esp-dl 原始 YUYV
out->data = (const uint8_t*)mmap_buffers_[buf.index].start;
out->len = buf.bytesused;
out->width = frame_.width;
out->height = frame_.height;
out->format = sensor_format_;
// 警告:返回给 caller 后必须立即用完,因为 VIDIOC_QBUF 后缓冲会被 ISP 覆写
// caller 在 run() 完成后立即 ReleaseDetectionFrame() 归还
return true;
}
// (需要配套 ReleaseDetectionFrame 做 VIDIOC_QBUF,此处省略)
注意: 上述代码是骨架示意,实际实现需要仔细处理 V4L2 缓冲区生命周期——可能更简单的做法是复用 Capture() 的 frame_.data(已 memcpy 到 PSRAM),face_tracker 直接访问 frame_(需把 frame_ 改为 protected 或新增 getter)。
UART 扩展(uart_component.h)
// 新增函数,维持现有 uart_send_string 不变
void uart_send_face(int x_offset, int y_offset);
// uart_component.cc 实现
void uart_send_face(int x_offset, int y_offset) {
char buf[24];
int n = snprintf(buf, sizeof(buf), "face:%d,%d", x_offset, y_offset);
if (n > 0 && n < (int)sizeof(buf)) {
uart_write_bytes(UART_PORT_NUM, buf, n);
uart_write_bytes(UART_PORT_NUM, "\r\n", 2);
}
}
RP2040 端扩展(coms.py)
# 在 Comms 类新增方法
def parse_face(self, line):
"""解析 ESP32 发来的 'face:X,Y' 坐标协议
Args:
line: 解码后的单行字符串(不含 \\r\\n)
Returns:
(x_offset, y_offset) tuple,或 None 如果格式不匹配
"""
if not line.startswith('face:'):
return None
try:
x_str, y_str = line[5:].split(',', 1)
return (int(x_str), int(y_str))
except (ValueError, IndexError):
return None
RP2040 端扩展(main.py L123-131)
# 原代码(L123-131)
incoming_commands = external.esp_read()
for data in incoming_commands:
if data in animation.action_map:
animation.action_map[data]()
elif data in animation.state_map:
animation.new_state_flag = True
animation.current_state = data
# 改造后:优先解析 face: 协议
incoming_commands = external.esp_read()
for data in incoming_commands:
face_offset = external.parse_face(data)
if face_offset is not None:
# 新增:ESP32 人脸坐标接管 grove_active
animation.grove_active = True
animation.grove_last_seen = time.ticks_ms()
external.last_face_offset = face_offset # 新增成员
continue
if data in animation.action_map:
animation.action_map[data]()
elif data in animation.state_map:
animation.new_state_flag = True
animation.current_state = data
然后改造 facetrack():
def facetrack():
global yaw_countdown, yaw_target
# 优先使用 ESP32 来源;无则 fallback 到 Grove
offset = getattr(external, 'last_face_offset', None)
if offset is None:
offset = external.grove_read()
# 清除 ESP32 offset(单次使用,避免同一坐标重复驱动舵机)
external.last_face_offset = None
# ... 后续保持现有逻辑
State of the Art
| 老做法 | 当前做法 | 改变时点 | 影响 |
|---|---|---|---|
esp-who face_detect() + dl_matrix3du_t |
esp-dl HumanFaceDetect::run(img_t&) |
esp-dl v3.0.0 (2023-12) | API 完全不兼容,老 esp-who 代码无法直接移植 |
esp32-camera + esp_camera_fb_get() |
esp_video + V4L2 ioctl(VIDIOC_DQBUF) |
ESP-IDF 5.2+ | 本项目已在用 esp_video |
| 硬编码模型到 rodata | Kconfig 可选 3 种 location(rodata/partition/sdcard) | human_face_detect v0.3.0 | 部署灵活性 |
| MSR01 + MNP01(v2) | MSR_S8_V1 + MNP_S8_V1(int8 量化) | esp-dl v3.1.0 (2025-01) | 模型更小、推理更快 |
已弃用的做法:
- CONFIG_BT_BLUFI_ENABLE:不相关,但注意 esp-dl 与 WiFi 并存无冲突
- 旧的
dl_matrix3du_t:v3.0.0 起被dl::image::img_t替代
Assumptions Log
| # | 假设 | 所在章节 | 风险 |
|---|---|---|---|
| A1 | Esp32Camera 的 YUYV 格式与 esp-dl 的 DL_IMAGE_PIX_TYPE_YUYV 字节序一致 |
Code Examples | 若字节序相反(YUYV vs UYVY)颜色通道错乱,推理精度下降(bbox 仍可用但 score 低)。缓解:第一轮测试打印 score,若 < 0.5 则切换到 UYVY 或转 RGB565 |
| A2 | ESP32-S3 QVGA 推理实测 FPS ≥ 10 | 摘要 | 实验室环境可能 8-12 FPS;若低于 5 FPS 需降级到 QQVGA 160×120 |
| A3 | uart_send_string 不会因同时被多任务调用产生乱码 |
Pattern 3 | UART driver 有内部锁,但 uart_write_bytes 本身不是 mutex-protected。缓解:新增 uart_mutex_,uart_send_string 和 uart_send_face 都加锁 |
| A4 | 新增任务的 PSRAM 用量 ~500KB 不会导致其他组件 OOM | Pitfall 4 | 当前项目 PSRAM 总量 8MB,预估占用 2-3MB(LVGL+帧+AEC 等),余量充足但未精确测量 |
| A5 | RP2040 coms.py 可以修改(不是只读代码库) |
RP2040 改造 | GOAL.md 明确 RP2040 目录可改 |
| A6 | 用户接受坐标发送频率固定为 10 FPS | 任务调度 | 需用户决策,见 Open Questions |
如果这张表为空: 所有关键声明都经过验证或引用 — 无需用户确认。
Open Questions
-
模型部署位置:rodata vs partition?
- 我们知道:人 face detect v0.4.1 支持三种位置;模型总大小 ~190KB(msr=60KB + mnp=127KB)。
- 不确定:是否愿意让
xiaozhi.bin增大 ~200KB(从 2.59MB 到 ~2.8MB,仍远小于 3.9MB ota 分区)?还是拆到独立 partition 以方便 OTA 独立更新? - 建议:选 rodata(方案 A),简化 OTA 流程。ota_0/ota_1 仍有 ~1.1MB 余量。
-
坐标发送频率:5 FPS vs 10 FPS vs 动态?
- 我们知道:推理耗时 ~38ms,理论上限 20 FPS;RP2040 舵机响应时间 ~50ms;UART 带宽充足。
- 不确定:用户希望人脸追踪有多"跟手"?过高帧率会加剧 Core 0 负载,可能影响音频。
- 建议:默认 10 FPS,通过 Kconfig 可调为 5/10/15。
-
检测范围:始终开启 vs 仅 speaking/listening 时开启?
- 我们知道:GOAL.md 说"不破坏现有功能"、"可维护性:支持无 Grove 和有 Grove 两种模式自动切换"。
- 不确定:idle 状态(未激活)时是否也要持续人脸检测?这会增加常态功耗。
- 建议:始终开启,简化状态机;如果 OOM/性能问题再加 Kconfig 关闭 idle 时检测。
-
face:x,y协议是否需要 score 字段?- 当前设计:只发坐标,不发置信度。
- 替代:
face:x,y,score\n,RP2040 端可根据 score 判断是否响应。 - 建议:先不发 score,减少解析复杂度;如果 false positive 多再加。
-
Kconfig 开关默认值:
yvsn?- 建议:
CONFIG_XIAOZHI_ENABLE_FACE_TRACKING default y,与 Phase 目标"人脸追踪是默认功能"匹配。 - 保留关闭选项以便调试、基线性能对比。
- 建议:
-
无脸超时策略:3 秒仍由 RP2040 处理 vs ESP32 显式发信号?
- 当前设计:ESP32 停止发
face:,RP2040 自动grove_active=False(复用现有 3s 超时)。 - 备选:ESP32 主动发
face:none\n,RP2040 立即切回随机动画。 - 建议:保持超时机制,避免新协议;但打 TODO 如果感觉延迟明显再加显式信号。
- 当前设计:ESP32 停止发
Environment Availability
| 依赖 | 谁需要 | 可用 | 版本 | 回退 |
|---|---|---|---|---|
| ESP-IDF | 构建系统 | ✓(dependencies.lock 锁定) | 5.4.2 | — |
| PSRAM(8MB OCT) | LVGL、帧缓冲、esp-dl | ✓ | N16R8 | 无(N16R0/N4R2 无法运行) |
| OV3660 + 飞线 | 摄像头采集 | ✓(已完成 3 根) | — | 若飞线虚焊需硬件重做 |
| UART1(GPIO17/18) | 发送坐标 | ✓ | — | — |
| RP2040 固件可修改 | 接收协议 | ✓(独立代码库) | MicroPython | — |
| esp-dl + human_face_detect | 推理 | ✗(需 idf.py add-dependency) |
待安装 3.2.0 + 0.4.1 | 无(Phase 核心) |
| OV3660 xiaozhi 兼容性 | Camera stack | ⚠️ | — | issue #1588 未 resolve;若复现需独立修复 |
缺失但无回退:
- esp-dl / human_face_detect — Phase 开工第一步就安装。
警示项:
- OV3660 + xiaozhi 的 issue #1588 — 需先 sanity test
cam->Capture()能工作;若失败先修驱动配置。
Validation Architecture
测试框架
| 属性 | 值 |
|---|---|
| 框架 | 无正式单元测试框架(xiaozhi 项目未使用 Unity) |
| 配置文件 | 无 |
| 快速运行命令 | 无—靠日志 + 手动测试 |
| 完整套件命令 | idf.py build && idf.py flash monitor |
说明: xiaozhi-esp32 项目本身没有 Unity/Catch2 测试基础设施,遵循手动 + 日志观测的验收方式。Phase 要求里规定了明确的 FPS / 延迟 / 功能正确性标准,用以下方式验收:
Phase 需求 → 测试映射
| 需求 | 行为 | 测试类型 | 自动化命令 | 文件存在? |
|---|---|---|---|---|
| REQ-01 | QVGA 帧率 ≥ 5 FPS | 日志观测 | grep "face detected" monitor.log | tail -20(估算间隔) |
N/A |
| REQ-02 | 人脸检测延迟 ≤ 200ms | 代码内置 timestamp | 在 face_tracker_task 里加 esp_timer_get_time() 前后对比,打 ESP_LOGI |
需新增埋点 |
| REQ-03 | 坐标传输延迟 ≤ 50ms | UART 波特率推算 | 115200 bps @ 14 bytes ≈ 1ms,主要看 RP2040 处理速度 | RP2040 端埋点 time.ticks_diff() |
| REQ-04 | 检测到脸时 RP2040 眼球追踪 | 手动观察 | 人在摄像头前移动,观察眼球 | 无 |
| REQ-05 | 无脸 3 秒后回退随机动画 | 手动观察 | 遮挡摄像头 3s,观察眼球是否切换 | 无 |
| REQ-06 | 语音对话不卡顿 | 手动观察 + 日志 | 启动 WebSocket 对话,开启 face_tracker,对比开关时的 Opus 日志 | 无 |
| REQ-07 | 唤醒词仍生效 | 手动观察 | 说"你好小智",检查是否进入 listening | 无 |
| REQ-08 | UART 现有状态字符串仍工作 | 日志 + 手动 | 触发 speaking/listening,观察 RP2040 反应 |
无 |
采样策略
- 每次代码修改: 本地
idf.py flash monitor,人工观察 FPS 日志和舵机动作 - 每个 wave 合并: 录制 30s 视频,包含:(a) 人脸追踪 (b) 语音对话 (c) 两者同时
- Phase gate: 在 CogNog V1.0 硬件上通过所有 8 个需求人工验收
Wave 0 Gaps
由于项目没有自动化测试框架,Wave 0 无需新增测试文件,但建议:
main/face_tracker.cc内置性能埋点(推理耗时、FPS、无脸计数)main/uart_component.cc内置 face 协议发送计数器(ESP_LOGI 每 10 秒一次)- RP2040
main.py在 parse_face 成功时打印print(f"ESP32 face: {offset}")
Sources
Primary (HIGH confidence)
- esp-dl v3.2.0 release notes (2025-10-23) — 确认最新版本、ESP-IDF 5.3+ 依赖、双核调度特性
- human_face_detect v0.4.1 on ESP Component Registry — 确认模型大小、推理延迟、API 签名
- esp-dl v3.2.0 dependencies — 确认 4 个依赖项和版本约束
- human_face_detect README — 确认 ESP32-S3 上 msr_s8_v1_s3 耗时 32.4ms, mnp_s8_v1_s3 耗时 5.6ms
- human_face_detect example code (master) — 完整 run() 使用示例
- dl_image_define.hpp — 确认支持 YUYV / RGB565LE / RGB888 等输入格式
- dl_image_process.hpp — 确认 ImageTransformer 自动处理 resize/normalize
- dl_image_color.hpp — 确认 YUV→RGB565/888 转换支持
- human_face_detect partitions2.csv — 确认分区名
human_face_det, 建议大小 200KB - 本项目源码:
main/boards/bread-compact-wifi-s3cam/、main/boards/common/esp32_camera.cc、main/uart_component.{h,cc}、main/application.cc、dependencies.lock— 所有集成点已读 - RP2040 源码:
/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/{main,coms,animation}.py— grove_active 机制、state_map/action_map、facetrack() 已读
Secondary (MEDIUM confidence)
- OV3660 FPS @ QVGA benchmark — 社区报告 18-20 FPS @ 20MHz XCLK(不同库但硬件相同,可作参考)
- ESP32-S3 face detection community reports — 官方声明 MSR01 可达 10-15 FPS
- xiaozhi issue #1588 OV3660 crash — 已知兼容性问题
Tertiary (LOW confidence,需实机验证)
- Core 0 vs Core 1 对音频的影响 — 基于 xiaozhi 项目已有经验法则(audio_input priority=8 Core 0, LVGL Core 1),无官方 esp-dl 文档明确要求
- 实际 PSRAM 占用 500KB — 估算值,需要运行
heap_caps_print_heap_info(MALLOC_CAP_SPIRAM)确认
Metadata
置信度分解:
- Standard Stack: HIGH — 所有版本号、API 签名、模型大小均通过官方源验证
- Architecture: HIGH — 现有代码已读,集成点清晰
- Pitfalls: MEDIUM-HIGH — Pitfall 1-2 有 GitHub issue 背书;Pitfall 3-4 基于 esp-dl 文档 + 通用嵌入式经验
- Performance numbers: MEDIUM — 官方 latency 数字 HIGH;FPS 上限是推算值,实测可能有 ±30% 偏差
Research date: 2026-04-17 Valid until: 2026-05-17(esp-dl 为活跃项目,30 天内可能发布 v3.3.0,届时需回查 release notes)
集成点清单(供 Planner 使用)
新增文件
main/face_tracker.h+main/face_tracker.cc— 新增,任务封装main/idf_component.yml— 追加 esp-dl + human_face_detect 依赖main/CMakeLists.txt— 在set(SOURCES ...)加"face_tracker.cc"
需修改文件
| 文件 | 行号 | 修改内容 |
|---|---|---|
main/uart_component.h |
结尾 | 新增 void uart_send_face(int x, int y); |
main/uart_component.cc |
结尾 | 新增 uart_send_face 实现 |
main/boards/common/esp32_camera.h |
L22 class 内 | 新增 CaptureForDetection() + FrameRef struct |
main/boards/common/esp32_camera.cc |
类外实现 | 新增 CaptureForDetection() 实现(或将 frame_ 改为 protected 提供 getter) |
main/application.cc |
L358 Start() 结尾 |
调用 face_tracker_start()(在 StartNetwork 之后) |
main/application.cc |
L704/714/726 | 不需要修改,现有 uart_send_string("idle"/"listening"/"speaking") 维持原状 |
main/Kconfig.projbuild |
Camera Configuration menu 结尾 | 新增 config XIAOZHI_ENABLE_FACE_TRACKING(bool, default y, depends on 有摄像头的板型) |
RP2040 端修改
| 文件 | 行号 | 修改内容 |
|---|---|---|
/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/coms.py |
新增方法 | parse_face(line) 解析 face:x,y 字符串 |
/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/coms.py |
__init__ |
新增 self.last_face_offset = None |
/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/main.py |
L123-131 | 在 for 循环里优先判断 parse_face |
/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/main.py |
L38-54 facetrack() |
优先用 external.last_face_offset,fallback 到 external.grove_read() |
无需修改的组件
main/main.cc—uart_init_component()调用点不变main/boards/bread-compact-wifi-s3cam/config.h— GPIO17/18 已在 uart_component.h 配置,无冲突partitions/v2/16m.csv— 选方案 A(rodata)无需改动sdkconfig.defaults.esp32s3— 无需改动(PSRAM 已启用)- 现有
display/display.ccL42uart_send_string(emotion)— 保持 - LVGL / 音频 / WiFi / WebSocket 等所有模块 — 保持
任务架构总结
| 任务名 | Core | 优先级 | 栈 | 说明 |
|---|---|---|---|---|
main_event_loop |
任意 | 3 | 8KB | 现有,不变 |
audio_input |
0 | 8 | 6KB | 现有,不变(最高优先级) |
audio_output |
任意 | 4 | 4KB | 现有,不变 |
| LVGL port task | 1 | 2 | 8KB(默认) | 现有 lcd_display.cc:131 task_affinity=1 |
face_track(新增) |
0 | 2 | 8KB | 10 FPS 限频,与 LVGL 分离 Core |
Core 分配理由:
- Core 0 已跑:main_event_loop(P=3)、audio_input(P=8)
- Core 1 已跑:LVGL port(P=2)
- face_track 放 Core 0 P=2:不会抢占 audio_input(高优先级),不与 LVGL 在同一 Core 争抢
- esp-dl 的 Conv2D 支持双核调度,会自动把计算分到 Core 1,理论上可用两核