Rdzleo e95d0c414e Phase 01 批次 1-3: 单摄像头人脸追踪基础设施
实现 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>
2026-04-17 18:24:27 +08:00

714 lines
36 KiB
Markdown
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 总推理耗时 ~38msFPS 上限约 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 同 Corepriority=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采集 QVGA320×240YUYV 帧,交给 `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 驱动 | 现有组件,无需改动 |
**安装命令:**
```bash
# 在 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` 里新增一个公开的"取当前帧引用"方法而不是为检测任务复制整帧
```cpp
// 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 任务 + 主动限频
```cpp
// 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()` 数据流
```python
# 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 指令用法复杂 |
| YUYVRGB888 转换 | 手写 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
| 类别 | 发现的项目 | 所需动作 |
|------|-----------|---------|
| **存储数据** | 不涉及 NVSSPIFFS 已有数据结构也不需要持久化坐标 | |
| **活跃服务配置** | 不涉及 n8n/Datadog/Cloudflare 等外部服务 | |
| **OS 级注册状态** | ESP32-S3 systemd/Task SchedulerFreeRTOS 任务完全 runtime 创建 | |
| **Secrets/环境变量** | 不需要新 API key | |
| **构建产物/已装包** | `managed_components/` 新增 `espressif__esp-dl`~10MB 源码+ `espressif__human_face_detect` | `idf.py reconfigure` 自动拉取首次构建耗时 +2-3 分钟 |
| **分区表 / Flash** | **若选方案 Arodata无改动方案 Bpartition需改 `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](https://github.com/78/xiaozhi-esp32/issues/1588) 报告 `compact-wifi-s3cam + OV3660` 调用 MCP camera tool 时冻结OV2640 正常
**根因:** OV3660 DMA/FIFO 配置与 OV2640 不同初始化参数需要分别处理本项目已经完成 3 根飞线GPIO 3514, 3641, 3742应在同一硬件上 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 0Opus 解码每 20ms 一次的缓冲送入会延迟出现"吱吱"卡顿
**根因:** esp-dl Conv2D 支持双核调度 task 本身 pin Core 0 只能借一部分时间片到 Core 1
**缓解方案:**
1. face_tracker 任务优先级 = 2低于 audio_output=4、audio_input=8、main_event_loop=3
2. 每次推理后主动 `vTaskDelay(0)` yield 一次
3. 使用 `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/s115200 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"的映射
```cpp
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
```cpp
// 来源: 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);
}
```
### 本项目适配版(建议模板)
```cpp
// 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`
```cpp
// 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 PSRAMface_tracker 直接访问 `frame_`需把 `frame_` 改为 protected 或新增 getter)。
### UART 扩展(`uart_component.h`
```cpp
// 新增函数,维持现有 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`
```python
# 在 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
```python
# 原代码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()`
```python
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 locationrodata/partition/sdcard | human_face_detect v0.3.0 | 部署灵活性 |
| MSR01 + MNP01v2 | MSR_S8_V1 + MNP_S8_V1int8 量化 | 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-3MBLVGL++AEC 余量充足但未精确测量 |
| A5 | RP2040 `coms.py` 可以修改不是只读代码库 | RP2040 改造 | GOAL.md 明确 RP2040 目录可改 |
| A6 | 用户接受坐标发送频率固定为 10 FPS | 任务调度 | 需用户决策 Open Questions |
**如果这张表为空:** 所有关键声明都经过验证或引用 无需用户确认
---
## Open Questions
1. **模型部署位置rodata vs partition**
- 我们知道 face detect v0.4.1 支持三种位置模型总大小 ~190KBmsr=60KB + mnp=127KB
- 不确定是否愿意让 `xiaozhi.bin` 增大 ~200KB 2.59MB ~2.8MB仍远小于 3.9MB ota 分区还是拆到独立 partition 以方便 OTA 独立更新
- 建议** rodata方案 A**简化 OTA 流程ota_0/ota_1 仍有 ~1.1MB 余量
2. **坐标发送频率5 FPS vs 10 FPS vs 动态?**
- 我们知道推理耗时 ~38ms理论上限 20 FPSRP2040 舵机响应时间 ~50msUART 带宽充足
- 不确定用户希望人脸追踪有多"跟手"过高帧率会加剧 Core 0 负载可能影响音频
- 建议**默认 10 FPS通过 Kconfig 可调为 5/10/15**。
3. **检测范围:始终开启 vs 仅 speaking/listening 时开启?**
- 我们知道GOAL.md "不破坏现有功能"、"可维护性支持无 Grove 和有 Grove 两种模式自动切换"。
- 不确定idle 状态未激活时是否也要持续人脸检测这会增加常态功耗
- 建议**始终开启**简化状态机如果 OOM/性能问题再加 Kconfig 关闭 idle 时检测
4. **`face:x,y` 协议是否需要 score 字段**
- 当前设计只发坐标不发置信度
- 替代`face:x,y,score\n`RP2040 端可根据 score 判断是否响应
- 建议**先不发 score**减少解析复杂度如果 false positive 多再加
5. **Kconfig 开关默认值:`y` vs `n`**
- 建议`CONFIG_XIAOZHI_ENABLE_FACE_TRACKING default y` Phase 目标"人脸追踪是默认功能"匹配
- 保留关闭选项以便调试基线性能对比
6. **无脸超时策略3 秒仍由 RP2040 处理 vs ESP32 显式发信号?**
- 当前设计ESP32 停止发 `face:`RP2040 自动 `grove_active=False`复用现有 3s 超时)。
- 备选ESP32 主动发 `face:none\n`RP2040 立即切回随机动画
- 建议**保持超时机制**避免新协议但打 TODO 如果感觉延迟明显再加显式信号
---
## Environment Availability
| 依赖 | 谁需要 | 可用 | 版本 | 回退 |
|------|--------|------|------|------|
| ESP-IDF | 构建系统 | ✓(dependencies.lock 锁定 | 5.4.2 | |
| PSRAM8MB OCT | LVGL帧缓冲esp-dl | | N16R8 | N16R0/N4R2 无法运行 |
| OV3660 + 飞线 | 摄像头采集 | ✓(已完成 3 | | 若飞线虚焊需硬件重做 |
| UART1GPIO17/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)](https://github.com/espressif/esp-dl/releases) 确认最新版本ESP-IDF 5.3+ 依赖双核调度特性
- [human_face_detect v0.4.1 on ESP Component Registry](https://components.espressif.com/components/espressif/human_face_detect) 确认模型大小推理延迟API 签名
- [esp-dl v3.2.0 dependencies](https://components.espressif.com/components/espressif/esp-dl/versions/3.2.0/dependencies) 确认 4 个依赖项和版本约束
- [human_face_detect README](https://github.com/espressif/esp-dl/tree/master/models/human_face_detect) 确认 ESP32-S3 msr_s8_v1_s3 耗时 32.4ms, mnp_s8_v1_s3 耗时 5.6ms
- [human_face_detect example code (master)](https://github.com/espressif/esp-dl/blob/master/examples/human_face_detect/main/app_main.cpp) 完整 run() 使用示例
- [dl_image_define.hpp](https://github.com/espressif/esp-dl/blob/master/esp-dl/vision/image/dl_image_define.hpp) 确认支持 YUYV / RGB565LE / RGB888 等输入格式
- [dl_image_process.hpp](https://github.com/espressif/esp-dl/blob/master/esp-dl/vision/image/dl_image_process.hpp) 确认 ImageTransformer 自动处理 resize/normalize
- [dl_image_color.hpp](https://github.com/espressif/esp-dl/blob/master/esp-dl/vision/image/dl_image_color.hpp) 确认 YUVRGB565/888 转换支持
- [human_face_detect partitions2.csv](https://github.com/espressif/esp-dl/blob/master/models/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_mapfacetrack() 已读
### Secondary (MEDIUM confidence)
- [OV3660 FPS @ QVGA benchmark](https://github.com/espressif/esp32-camera/issues/232) 社区报告 18-20 FPS @ 20MHz XCLK不同库但硬件相同可作参考
- [ESP32-S3 face detection community reports](https://www.espressif.com/en/products/devkits/esp-eye/overview) 官方声明 MSR01 可达 10-15 FPS
- [xiaozhi issue #1588 OV3660 crash](https://github.com/78/xiaozhi-esp32/issues/1588) 已知兼容性问题
### 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 数字 HIGHFPS 上限是推算值实测可能有 ±30% 偏差
**Research date:** 2026-04-17
**Valid until:** 2026-05-17esp-dl 为活跃项目30 天内可能发布 v3.3.0届时需回查 release notes
---
## 集成点清单(供 Planner 使用)
### 新增文件
1. `main/face_tracker.h` + `main/face_tracker.cc` 新增任务封装
2. `main/idf_component.yml` 追加 esp-dl + human_face_detect 依赖
3. `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` 选方案 Arodata无需改动
- `sdkconfig.defaults.esp32s3` 无需改动PSRAM 已启用
- 现有 `display/display.cc` L42 `uart_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_loopP=3、audio_inputP=8
- Core 1 已跑LVGL portP=2
- face_track Core 0 P=2不会抢占 audio_input高优先级不与 LVGL 在同一 Core 争抢
- esp-dl Conv2D 支持双核调度会自动把计算分到 Core 1理论上可用两核