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

1201 lines
60 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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: 单摄像头人脸追踪 — 执行计划PLAN.md
**创建日期:** 2026-04-17
**对应 GOAL:** `docs/phase-01-face-tracking/GOAL.md`
**对应 RESEARCH:** `docs/phase-01-face-tracking/RESEARCH.md`
**计划作者:** GSD Planner
**目标 ESP-IDF 版本:** 5.4.2
---
## 修订历史Revision History
| 日期 | 版本 | 修订人 | 基于 | 主要变更 |
|------|------|--------|------|---------|
| 2026-04-17 | v1.0 | GSD Planner | 初稿 | 首次发布 15 个任务 |
| 2026-04-17 | v1.1 | GSD Planner | PLAN_CHECK.md 反馈 | 修复 3 个 BLOCKER + 3 个 HIGH新增 D-07重写 T01/T04/T06/T08/T10修正依赖图 T05→T07 为 T06→T07 |
**v1.1 涉及修改的任务:** T01、T04、T06、T08、T10含 ESP32 与 RP2040 侧)
**v1.1 新增决策:** D-07idle 状态 RP2040 不驱动眼球)
**v1.1 依赖图修正:** T06→T07T07 被 T06 调用),原图中 T05→T07 是笔误
---
## 0. 顶层摘要Executive Summary
| 项目 | 值 |
|------|----|
| 涉及代码库 | ESP32 项目(本仓库)+ RP2040 项目(`/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040` |
| 总任务数 | **15** 个原子任务T01-T15 |
| 任务分组 | A 准备(3) / B 人脸检测(3) / C ESP32 UART 协议(1) / D RP2040 端协议(3) / E 集成与验证(5) |
| 估算总工时 | **1622 小时** Claude 执行 + **35 小时** 用户实机验证 |
| 关键依赖 | esp-dl v3.2.0 + human_face_detect v0.4.1(新装 managed 组件) |
| 默认开关 | `CONFIG_XIAOZHI_ENABLE_FACE_TRACKING=y`(面向生产默认开启) |
| 默认帧率 | `CONFIG_XIAOZHI_FACE_TRACKING_FPS=10`(可配 5/10/15 |
| 唯一新增 UART 协议 | `face:x,y\n`(不含 score不破坏现有 state/action 字典) |
| 模型部署方式 | Flash rodata`human_face_detect` 组件自身 embed无分区改动 |
### 用户已决策(锁定,不可动)
| ID | 决策 |
|----|------|
| D-01 | 模型部署:嵌入 rodata跟随固件不建独立分区 |
| D-02 | 坐标频率:默认 10 FPSKconfig 可配 5/10/15 |
| D-03 | ESP32 侧始终运行人脸检测并发送坐标(含 idle 状态) |
| D-04 | UART 协议:`face:x,y\n`,不带 score |
| D-05 | Kconfig 开关:`CONFIG_XIAOZHI_ENABLE_FACE_TRACKING` 默认 `y` |
| D-06 | 无脸处理ESP32 不主动发 "no face",复用 RP2040 现有 3 秒超时 |
| **D-07** | **【v1.1 新增】idle 状态下 RP2040 不驱动眼球舵机追踪。ESP32 侧行为不变(按 D-03 始终发送坐标RP2040 侧收到 `face:x,y` 时,若 `animation.current_state == "idle"`,仅更新 `grove_active`/`grove_last_seen`/`last_face_offset` 状态,不调用 eyl/eyr/pit/yaw 的 `set_target()`。理由idle 状态眼睑闭合LID=30°此时驱动眼球在视觉上既无意义又诡异** |
每个决策在下面任务表中用 `[D-XX]` 标签回链。
---
## 1. 任务拆分Task Breakdown
所有路径无前缀的为 **ESP32 项目**`/Users/rdzleo/Desktop/CogletESP-camera-version`)相对路径;
凡 RP2040 端任务**明确标注**绝对路径前缀 `/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/`
### Group A准备工作依赖、Kconfig、摄像头接口
---
#### T01 — 摄像头硬件 sanity probe最小化 V4L2 DQBUF/QBUF 探测)[修订于 2026-04-17]
- **所属代码库:** ESP32
- **修订说明v1.1:** 原版使用完整 `Capture()`(含 JPEG 编码 + PSRAM 整帧分配)做 probe代价过大且语义不匹配 issue #1588 的根因V4L2 层)。现改为最小化 probe——只调一次 V4L2 DQBUF + QBUF验证 V4L2 原始帧采集链路,不触发 JPEG 编码和大内存分配。
- **需要修改文件:** 无持久改动;在 `main/boards/common/esp32_camera.h/.cc` 新增**临时**的 `ProbeFrameCapture()` 方法,或在 `main.cc` 直接用 C API 调用 ioctl
- **Action:**
1.`Esp32Camera` 类添加临时调试接口(仅 T01 用):
```cpp
// esp32_camera.h 新增T04 完成后可删除)
bool ProbeFrameCapture(int64_t* elapsed_us);
// esp32_camera.cc 新增实现
bool Esp32Camera::ProbeFrameCapture(int64_t* elapsed_us) {
if (!streaming_on_ || video_fd_ < 0) {
ESP_LOGE(TAG, "Probe 失败streaming 未启动或 video_fd 无效");
return false;
}
int64_t t0 = esp_timer_get_time();
struct v4l2_buffer buf = {};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
// 只做一次 DQBUF验证 V4L2 能获取帧
if (ioctl(video_fd_, VIDIOC_DQBUF, &buf) != 0) {
ESP_LOGE(TAG, "Probe 失败VIDIOC_DQBUF 返回错误");
return false;
}
size_t bytes_used = buf.bytesused;
// 立即归还,避免占用缓冲
if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) {
ESP_LOGE(TAG, "Probe 失败VIDIOC_QBUF 归还失败");
return false;
}
int64_t t1 = esp_timer_get_time();
if (elapsed_us) *elapsed_us = t1 - t0;
ESP_LOGI(TAG, "Probe 成功bytesused=%u elapsed=%lldus", (unsigned)bytes_used, (long long)(t1 - t0));
return true;
}
```
2. 在 `Application::Start()` 末尾(`protocol_->Start()` 之后)临时插入:
```cpp
// T01: 摄像头 V4L2 原始采集 sanity probePhase 1 验证完后删除)
auto* cam = dynamic_cast<Esp32Camera*>(board.GetCamera());
if (cam) {
int64_t elapsed = 0;
bool ok = cam->ProbeFrameCapture(&elapsed);
ESP_LOGI("T01_Probe", "V4L2 probe result=%d elapsed=%lldus", ok, (long long)elapsed);
} else {
ESP_LOGW("T01_Probe", "no camera instance");
}
```
3. `idf.py build flash monitor`,观察启动日志:应看到 `Probe 成功: bytesused=>0 elapsed<200000us`< 200ms
4. **T04 完成后**删除临时 probe 调用(但保留 `ProbeFrameCapture` 作为诊断 API或整体删除——本任务 DoD 要求 probe 调用删除)。
- **DoD:**
- 控制台看到 `T01_Probe: V4L2 probe result=1 elapsed<200000us`
- 未出现 `v4l2` 层 panic 或 `VIDIOC_DQBUF` 错误;
- issue #1588 在本机硬件上被证伪V4L2 层可用);
- 探测耗时 `elapsed < 200ms`,表明 DVP 采集正常。
- **复杂度:** Small
- **需要用户介入:** **是** — 需用户手头有 CogNog V1.0 硬件,并在 monitor 里观察日志。
- **Risk mitigation:** 若 probe 返回 false 或 panic**暂停整个 Phase**,回到 RESEARCH 补充 OV3660 驱动调试章节。若 DQBUF 返回成功但 `bytesused=0`,说明 sensor 配置问题,需单独排查。
- **设计权衡:** 没有选择"合并进 T04"的方案,因为保留一个可独立触发的最小 probe 更便于后续诊断(例如遇到 issue 时用户可单独调用此方法)。
---
#### T02 — 新增 Kconfig 开关(`CONFIG_XIAOZHI_ENABLE_FACE_TRACKING` + FPS choice
- **所属代码库:** ESP32
- **需要修改文件:**
- `main/Kconfig.projbuild`(在 "Enable Camera Image Rotation" 附近、`endmenu` 之前插入)
- **Action:**
```kconfig
# 在 menu "Xiaozhi Assistant" 的 Camera Configuration 区endmenu 前)加入:
config XIAOZHI_ENABLE_FACE_TRACKING
bool "Enable ESP32 face tracking (replaces Grove Vision AI)"
default y
depends on IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4 # esp-dl 仅支持 S3/P4
help
开启后 ESP32 利用板载 OV3660 + esp-dl 做人脸检测,
通过 UART1 把归一化坐标 face:x,y\n 发给 RP2040
替代 Grove Vision AI V2 模块。关闭后退回纯显示/MCP 相机模式。
if XIAOZHI_ENABLE_FACE_TRACKING
choice XIAOZHI_FACE_TRACKING_FPS_CHOICE
prompt "Face tracking coordinate FPS"
default XIAOZHI_FACE_TRACKING_FPS_10
help
控制向 RP2040 发送坐标的频率值越高追踪越跟手、CPU 占用越大。
config XIAOZHI_FACE_TRACKING_FPS_5
bool "5 FPS (低 CPU适合调试音频冲突)"
config XIAOZHI_FACE_TRACKING_FPS_10
bool "10 FPS (推荐默认)"
config XIAOZHI_FACE_TRACKING_FPS_15
bool "15 FPS (最大跟手,慎用)"
endchoice
config XIAOZHI_FACE_TRACKING_FPS
int
default 5 if XIAOZHI_FACE_TRACKING_FPS_5
default 10 if XIAOZHI_FACE_TRACKING_FPS_10
default 15 if XIAOZHI_FACE_TRACKING_FPS_15
endif
```
- **DoD:** `idf.py menuconfig` 可以在 "Xiaozhi Assistant" 菜单看到该选项并切换;`sdkconfig.h` 生成正确的 `CONFIG_XIAOZHI_FACE_TRACKING_FPS`ESP32-C3/C6 等目标上该选项不可见。
- **复杂度:** Small
- **需要用户介入:** 否 [D-02][D-05]
---
#### T03 — 新增 esp-dl 与 human_face_detect 依赖
- **所属代码库:** ESP32
- **需要修改文件:**
- `main/idf_component.yml`
- **Action:** 在 `dependencies:` 下新增两行(与 `espressif/esp_video` 同缩进):
```yaml
espressif/esp-dl:
version: "==3.2.0"
rules:
- if: target in [esp32s3, esp32p4]
espressif/human_face_detect:
version: "==0.4.1"
rules:
- if: target in [esp32s3, esp32p4]
```
执行:
```bash
cd /Users/rdzleo/Desktop/CogletESP-camera-version
idf.py reconfigure
```
确认 `managed_components/espressif__esp-dl/` 和 `managed_components/espressif__human_face_detect/` 目录存在;检查 `dependencies.lock` 被更新。
- **DoD:** `managed_components` 出现 2 个新组件目录;`idf.py build` 第一次会多耗 ~2-3 分钟但编译通过;`dependencies.lock` 的变更纳入同一 commit。
- **复杂度:** Small
- **需要用户介入:** 否 [D-01]
- **Risk:** 若 `idf.py reconfigure` 报 `Can't find version` → 查 `components.espressif.com` 是否版本号变化,相应更新 version。
---
### Group B人脸检测核心Camera 接口扩展 + FaceDetector 封装 + Task
---
#### T04 — `Esp32Camera` 新增 `CaptureForDetection()` 接口 + 双超时 mutex 策略 [修订于 2026-04-17]
- **所属代码库:** ESP32
- **修订说明v1.1:** 原版计划使用共享 `capture_mutex_` 的纯 `try_lock`,会导致 MCP 拍照(`Capture()` 耗时数百 ms 到数秒)期间 face_track 完全饥饿。现改为**超短 timeout 跳帧策略**face_track 拿不到锁就跳过这一帧(人脸检测允许丢帧),拍照则可完整持有 mutex。
- **需要修改文件:**
- `main/boards/common/esp32_camera.h`
- `main/boards/common/esp32_camera.cc`
- **Action:**
1. 在 `esp32_camera.h` 的 `Esp32Camera` 类内新增(包含 `#include <mutex>` 和 `#include <freertos/semphr.h>`
```cpp
struct FrameRef {
const uint8_t* data = nullptr;
size_t len = 0;
uint16_t width = 0;
uint16_t height = 0;
v4l2_pix_fmt_t format = 0;
uint32_t buf_index = 0; // 用于 QBUF 归还
};
// 人脸检测用:超短 timeout拿不到锁即跳帧返回 false
bool CaptureForDetection(FrameRef* out);
bool ReleaseDetectionFrame(const FrameRef& ref);
```
并在 private 段新增:
```cpp
SemaphoreHandle_t capture_mutex_ = nullptr; // 用 FreeRTOS 信号量而非 std::mutex
// 因为需要 timeout 语义std::mutex 无 timed_lock on ESP32 toolchain 可移植)
```
2. 构造函数里创建:`capture_mutex_ = xSemaphoreCreateMutex();`
3. 在 `esp32_camera.cc` 实现 `CaptureForDetection()` 和 `ReleaseDetectionFrame()`
```cpp
bool Esp32Camera::CaptureForDetection(FrameRef* out) {
if (!streaming_on_ || video_fd_ < 0 || !out) return false;
// 超短 timeout 策略10ms 拿不到锁就跳过这一帧
// 原因MCP Capture() 可能耗时 500-3000ms人脸检测允许丢帧拍照不允许丢
if (xSemaphoreTake(capture_mutex_, pdMS_TO_TICKS(10)) != pdTRUE) {
return false; // 让 face_tracker 自然跳过下一帧
}
struct v4l2_buffer buf = {};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
if (ioctl(video_fd_, VIDIOC_DQBUF, &buf) != 0) {
xSemaphoreGive(capture_mutex_);
return false;
}
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_;
out->buf_index = buf.index;
// 注意:不解锁!由 ReleaseDetectionFrame 配对解锁
return true;
}
bool Esp32Camera::ReleaseDetectionFrame(const FrameRef& ref) {
if (!streaming_on_ || video_fd_ < 0) {
xSemaphoreGive(capture_mutex_);
return false;
}
struct v4l2_buffer buf = {};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
buf.memory = V4L2_MEMORY_MMAP;
buf.index = ref.buf_index;
int ret = ioctl(video_fd_, VIDIOC_QBUF, &buf);
xSemaphoreGive(capture_mutex_);
return ret == 0;
}
```
4. 修改现有 `Capture()` 开头,加 `xSemaphoreTake(capture_mutex_, portMAX_DELAY)`;所有 return 路径前 `xSemaphoreGive`(用 RAII 辅助类简化或手工管理。MCP 拍照是同步阻塞操作,可以等任意时长。
- **DoD:**
- `idf.py build` 编译通过;
- 加一段临时测试:在 `Start()` 末尾延时 3s连续调用 `CaptureForDetection()`/`ReleaseDetectionFrame()` 10 次,观察 `frame.len > 0` 且每次 `data` 指针可能不同mmap 缓冲轮转);
- 模拟 MCP 拍照场景:后台启动 face_tracker 后手动调 `Capture()`,观察 face_tracker 在 `Capture()` 期间**跳帧而非死等**(日志中 `CaptureForDetection` 返回 false 的次数接近 `Capture()` 耗时 / 100ms
- **人脸检测允许丢帧,拍照不允许丢** —— 该语义在代码注释中明确标注。
- **复杂度:** Medium
- **需要用户介入:** 否
- **Risk:** DVP 只申请 1 个 V4L2 缓冲区——若 DQBUF 没及时 QBUF 会死锁。实现里要保证"任何返回 true 的分支都有配对的 ReleaseDetectionFrame",否则后续 Capture 也会失败。
---
#### T05 — 新增 `face_tracker.{h,cc}`(骨架 + Kconfig 守卫 + 任务生命周期)[修订于 2026-04-17]
- **所属代码库:** ESP32
- **修订说明v1.1:** 明确 CMakeLists.txt 中对非 S3 目标的处理方式,确保 ESP32非 S3/ C3 / C6 即使 `CONFIG_XIAOZHI_ENABLE_FACE_TRACKING=y`(虽然 Kconfig 会禁止显示)也不会因 include `human_face_detect.hpp` 失败而编译失败。
- **需要创建文件:**
- `main/face_tracker.h`
- `main/face_tracker.cc`
- **需要修改文件:**
- `main/CMakeLists.txt`(条件编译源文件)
- **Action:**
1. `face_tracker.h` 导出 3 个 C 可见接口(便于将来也能被 `.c` 文件调用):
```cpp
#pragma once
#ifdef __cplusplus
extern "C" {
#endif
// 启动人脸检测任务Kconfig 未开启时本函数为空实现
void face_tracker_start(void);
// 请求停止(异步,任务会在下一帧自行退出)
void face_tracker_stop(void);
// 供日志/诊断查询最近一次检测的 FPS实际推送频率
float face_tracker_get_fps(void);
#ifdef __cplusplus
}
#endif
```
2. `face_tracker.cc`
- **文件顶部用平台+功能双重守卫**,所有 esp-dl 相关 include 都在守卫块内:
```cpp
#include "face_tracker.h"
#include "sdkconfig.h"
#if defined(CONFIG_XIAOZHI_ENABLE_FACE_TRACKING) && defined(CONFIG_IDF_TARGET_ESP32S3)
// 只有 S3 + 功能启用时才 include esp-dl 相关头
#include "human_face_detect.hpp"
#include "dl_image_define.hpp"
#include "board.h"
#include "esp32_camera.h"
#include "uart_component.h"
#include <esp_log.h>
#include <esp_timer.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
static const char* TAG = "FaceTracker";
static TaskHandle_t s_handle = nullptr;
static volatile bool s_stop = false;
static float s_last_fps = 0.0f;
static void face_tracker_task(void* arg) {
// T05 阶段:先打印 helloT06 再加推理
while (!s_stop) {
ESP_LOGI(TAG, "face_tracker: hello from core %d", xPortGetCoreID());
vTaskDelay(pdMS_TO_TICKS(1000));
}
s_handle = nullptr;
vTaskDelete(NULL);
}
extern "C" void face_tracker_start(void) {
if (s_handle) return;
s_stop = false;
xTaskCreatePinnedToCore(face_tracker_task, "face_track",
8 * 1024, nullptr, 2, &s_handle, 0);
}
extern "C" void face_tracker_stop(void) {
s_stop = true;
}
extern "C" float face_tracker_get_fps(void) {
return s_last_fps;
}
#else // 非 S3 或功能关闭:提供空壳,保证链接通过
extern "C" void face_tracker_start(void) {}
extern "C" void face_tracker_stop(void) {}
extern "C" float face_tracker_get_fps(void) { return 0.0f; }
#endif // CONFIG_XIAOZHI_ENABLE_FACE_TRACKING && CONFIG_IDF_TARGET_ESP32S3
```
3. `CMakeLists.txt` 条件编译策略(仿照现有 `boards/common/esp32_camera.cc` 的处理):
```cmake
# 在 main/CMakeLists.txt 的 set(SOURCES ...) 里追加 "face_tracker.cc"
set(SOURCES
...
"uart_component.cc"
"face_tracker.cc" # 新增
)
# 在文件末尾(或 idf_component_register 前),模仿 esp32_camera.cc 的处理加:
if(NOT CONFIG_IDF_TARGET_ESP32S3)
list(REMOVE_ITEM SOURCES "face_tracker.cc")
endif()
```
这样做双重保障:
- 代码层面:`#if` 守卫使非 S3 下 `face_tracker.cc` 编译成空文件3 个空函数);
- 构建层面CMakeLists.txt 在非 S3 时直接不编译该文件(避免无谓的空编译)。
两种机制任一生效都能保证非 S3 目标构建成功。
4. **明确列出目标兼容性:**
| 目标 | Kconfig 可见 | face_tracker.cc 编译 | 行为 |
|------|-------------|---------------------|------|
| ESP32-S3 | 是 | 完整编译 | 正常工作 |
| ESP32-P4 | 是 | 空壳(`#else` 分支) | 占位,不工作 |
| ESP32原版| 否 | 不编译CMake 排除) | 无任何影响 |
| ESP32-C3 / C6 | 否 | 不编译CMake 排除) | 无任何影响 |
- **DoD:**
- ESP32-S3 编译通过;
- ESP32原版编译通过验证非 S3 目标不会因 `human_face_detect.hpp` 缺失而失败);
- T11 把启动调用加入后,`monitor` 能每秒看到一行 `face_tracker: hello from core 0`
- `vTaskDelete(NULL)` 在 stop 路径成功(可以在 T15 集成测试时验证)。
- **复杂度:** Small
- **需要用户介入:** 否
---
#### T06 — 在 `face_tracker.cc` 集成 `HumanFaceDetect` 推理 + 坐标归一化
- **所属代码库:** ESP32
- **需要修改文件:**
- `main/face_tracker.cc`
- **Action:**
1. 替换 T05 骨架中的 `face_tracker_task` 为完整实现(仍在 `#if defined(CONFIG_XIAOZHI_ENABLE_FACE_TRACKING) && defined(CONFIG_IDF_TARGET_ESP32S3)` 守卫内):
```cpp
static void face_tracker_task(void*) {
vTaskDelay(pdMS_TO_TICKS(500)); // 等待摄像头 ISP 预热 + 视频流跑起来
auto* detector = new(std::nothrow) HumanFaceDetect();
if (!detector) {
ESP_LOGE(TAG, "HumanFaceDetect 初始化失败PSRAM 不足?)");
// 启动一次性打印 PSRAM 余量供诊断
multi_heap_info_t info;
heap_caps_get_info(&info, MALLOC_CAP_SPIRAM);
ESP_LOGE(TAG, "PSRAM free=%u", info.total_free_bytes);
s_handle = nullptr;
vTaskDelete(NULL);
return;
}
// 启动时一次性打印 PSRAM 占用供诊断
multi_heap_info_t info;
heap_caps_get_info(&info, MALLOC_CAP_SPIRAM);
ESP_LOGI(TAG, "PSRAM after detector init: free=%u allocated=%u",
info.total_free_bytes, info.total_allocated_bytes);
const TickType_t period = pdMS_TO_TICKS(1000 / CONFIG_XIAOZHI_FACE_TRACKING_FPS);
TickType_t last_wake = xTaskGetTickCount();
int hit = 0, miss = 0;
int64_t last_report_us = esp_timer_get_time();
while (!s_stop) {
vTaskDelayUntil(&last_wake, period);
auto* cam = dynamic_cast<Esp32Camera*>(Board::GetInstance().GetCamera());
if (!cam) continue;
Esp32Camera::FrameRef f;
if (!cam->CaptureForDetection(&f)) {
// T04 策略:拿不到 mutex 跳帧MCP 拍照中),正常现象
continue;
}
dl::image::img_t img{};
img.data = (void*)f.data;
img.width = f.width;
img.height = f.height;
// RESEARCH Pitfall A1先假定 YUYV若首轮 score 低可改成 RGB565LE决策点 D-B
img.pix_type = dl::image::DL_IMAGE_PIX_TYPE_YUYV;
int64_t t0 = esp_timer_get_time();
auto& results = detector->run(img);
int64_t t1 = esp_timer_get_time();
cam->ReleaseDetectionFrame(f); // 立即归还缓冲!
if (results.empty()) {
miss++;
} else {
hit++;
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 / f.width - 112;
int y_offset = cy * 224 / f.height - 112;
uart_send_face(x_offset, y_offset); // T07 提供
ESP_LOGD(TAG, "face score=%.2f offset=(%d,%d) infer=%lldus",
r.score, x_offset, y_offset, (long long)(t1 - t0));
}
// 每 10 秒汇报一次统计(加保底避免除零)
int64_t now = esp_timer_get_time();
if (now - last_report_us > 10'000'000) {
float elapsed_s = (now - last_report_us) / 1e6f;
if (elapsed_s > 0.1f) {
s_last_fps = (hit + miss) / elapsed_s;
ESP_LOGI(TAG, "face stats: hit=%d miss=%d fps≈%.1f",
hit, miss, s_last_fps);
}
hit = miss = 0; last_report_us = now;
}
}
delete detector;
s_handle = nullptr;
vTaskDelete(NULL);
}
```
2. 坐标映射公式必须**严格与 RESEARCH Pitfall 7 一致**`cx * 224 / width - 112`),否则 RP2040 端 `deadzone=20, x_adj_factor=10` 会失准。
- **DoD:**
- 无脸时日志持续打印 `face stats: hit=0 miss=X`
- 镜头前有人脸时每秒打印多条 `face score=0.XX offset=(..,..) infer=XXus`
- 10 秒一次的统计显示 `fps≈` 接近 `CONFIG_XIAOZHI_FACE_TRACKING_FPS`
- 推理耗时 `infer=` 在 30-80ms 之间S3 + QVGA
- 启动时打印一次 `PSRAM after detector init: free=... allocated=...`。
- **复杂度:** Medium
- **需要用户介入:** **是(实测观察)** — 需把摄像头对准人脸验证 score。
---
### Group CESP32 端 UART 协议扩展
---
#### T07 — `uart_component` 新增 `uart_send_face()` + 互斥保护
- **所属代码库:** ESP32
- **需要修改文件:**
- `main/uart_component.h`
- `main/uart_component.cc`
- **Action:**
1. `uart_component.h` 新增(声明末尾):
```cpp
// 发送人脸检测坐标,格式:"face:<x>,<y>\r\n"
// x,y ∈ [-112, +112]RP2040 端 pixel_centre=112 解析
void uart_send_face(int x_offset, int y_offset);
```
2. `uart_component.cc`
- 文件顶部新增 `#include <freertos/semphr.h>` 和一个 `static SemaphoreHandle_t s_uart_tx_mutex = nullptr;`
- 在 `uart_init_component()` 末尾 `s_uart_tx_mutex = xSemaphoreCreateMutex();`
- 修改 `uart_send_string()`:整个函数体外包一层 `xSemaphoreTake(s_uart_tx_mutex, portMAX_DELAY) ... Give`(防多任务同时发送乱码 —— RESEARCH A3
- 新增:
```cpp
void uart_send_face(int x_offset, int y_offset) {
if (s_uart_tx_mutex == nullptr) return;
char buf[24];
int n = snprintf(buf, sizeof(buf), "face:%d,%d", x_offset, y_offset);
if (n <= 0 || n >= (int)sizeof(buf)) return;
xSemaphoreTake(s_uart_tx_mutex, portMAX_DELAY);
uart_write_bytes(UART_PORT_NUM, buf, n);
uart_write_bytes(UART_PORT_NUM, "\r\n", 2);
xSemaphoreGive(s_uart_tx_mutex);
}
```
- **DoD:**
- 编译通过;
- 用临时 test hook 在 `Start()` 里调 `uart_send_face(42, -30)`RP2040 端 `print(incoming_commands)` 能看到 `['face:42,-30']`T09 之前T08 完成也可验证,必须确认 `\r\n` 被正确 strip 掉,只剩 `face:42,-30`)。
- **复杂度:** Small
- **需要用户介入:** 否 [D-04]
---
### Group DRP2040 端协议识别
---
#### T08 — RP2040 `coms.py` 新增 `parse_face()` + `last_face_offset` 状态 + static 去重 [修订于 2026-04-17]
- **所属代码库:** **RP2040**
- **修订说明v1.1:** 新增 static 去重逻辑BLOCKER #2 修复。ESP32 以 10 FPS 持续发送坐标,即使人不动坐标也会每 100ms 到达一次。若不做去重,`facetrack()` 中的 `if not static:` 会一直进入驱动分支,导致 `set_target()` 持续累加偏移,最终眼球"漂移到边界卡死"。现在 `parse_face` 成功后在 coms 层做 static 判定:新坐标与上次差异 < 阈值即设 `staticflag = True`。
- **需要修改文件:**
- `/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/coms.py`
- **Action:**
1. 在 `Comms.__init__` 末尾新增:
```python
# ESP32 人脸坐标最新值main.py 消费后置 None单次使用
self.last_face_offset = None
# 用于 static 去重:记录上次收到的坐标
# 若新坐标与此差异 < FACE_STATIC_THRESHOLD视为"没动"
self.last_face_raw = None
# static 阈值±3 像素(在归一化 [-112, +112] 下约 2.7%
# 经验值:消除 ESP32 人脸检测 bbox 抖动,同时保留真实移动的响应
self.FACE_STATIC_THRESHOLD = 3
```
2. 在类内新增方法:
```python
def parse_face(self, line):
"""解析 ESP32 发来的 'face:X,Y' 字符串,并更新 staticflag。
实现说明BLOCKER #2 修复):
ESP32 以 10 FPS 持续发送坐标,人不动时每 100ms 会收到相近坐标。
此处与 last_face_raw 对比,若差异小于 FACE_STATIC_THRESHOLD 就把
self.staticflag 置为 Truefacetrack() 中 `if not static:` 分支
就不会驱动舵机,避免 target 持续累加漂移。
Args:
line: 解码后的单行字符串(不含 \\r\\n
Returns:
(x_offset, y_offset) tuple或 None 如果格式不匹配
"""
if not line.startswith('face:'):
return None
try:
body = line[5:]
x_str, y_str = body.split(',', 1)
coord = (int(x_str), int(y_str))
except (ValueError, IndexError):
return None
# Static 去重判定
if self.last_face_raw is not None:
dx = abs(coord[0] - self.last_face_raw[0])
dy = abs(coord[1] - self.last_face_raw[1])
if dx <= self.FACE_STATIC_THRESHOLD and dy <= self.FACE_STATIC_THRESHOLD:
self.staticflag = True
else:
self.staticflag = False
self.last_face_raw = coord
else:
# 首次收到坐标视为非静态(确保眼球初始化时会看向人脸)
self.staticflag = False
self.last_face_raw = coord
return coord
```
- **DoD:**
- Thonny/REPL 里手动调用:
```python
c = coms.Comms()
# 基本解析
assert c.parse_face('face:42,-30') == (42, -30)
assert c.parse_face('face:-112,0') == (-112, 0)
assert c.parse_face('idle') is None
assert c.parse_face('face:bad,data') is None
# Static 去重测试
c.last_face_raw = None # 重置
c.parse_face('face:50,20') # 首次staticflag=False
assert c.staticflag == False
c.parse_face('face:51,22') # 差异 1,2 ≤ 3 → staticflag=True
assert c.staticflag == True
c.parse_face('face:60,22') # 差异 9,0 > 3 → staticflag=False
assert c.staticflag == False
c.parse_face('face:60,22') # 完全相同 → staticflag=True
assert c.staticflag == True
```
- **复杂度:** Small
- **需要用户介入:** 否(但需要用户用 Thonny 连接 RP2040 跑一次)
- **Risk:** `FACE_STATIC_THRESHOLD=3` 的阈值是经验值T12 实测时可能需要调整。若眼球仍然漂移 → 增大阈值;若真实微动也被吞掉 → 减小阈值。
---
#### T09 — RP2040 `main.py` 在 incoming_commands 循环里优先识别 `face:`
- **所属代码库:** **RP2040**
- **需要修改文件:**
- `/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/main.py`(约 L122-L132 的 for 循环)
- **Action:** 把原 for 循环改为:
```python
incoming_commands = external.esp_read()
for data in incoming_commands:
# 优先识别 ESP32 人脸坐标协议face:x,y
# parse_face 内部会自动更新 external.staticflag见 T08
face_offset = external.parse_face(data)
if face_offset is not None:
external.last_face_offset = face_offset
animation.grove_active = True
animation.grove_last_seen = time.ticks_ms()
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
```
**注意:** `grove_active` / `grove_last_seen` 的更新**只在此处**做T10 的 `facetrack()` 不再重复更新BLOCKER #2 修复,见 T10 的修订说明)。
- **DoD:**
- 用 Thonny 在 REPL 模拟:`external.last_face_offset = None`,再模拟一串 `esp_read` 返回 `['face:30,-10', 'speaking']`,确认循环结束后 `external.last_face_offset == (30,-10)` 且 `animation.current_state == 'speaking'`。
- **复杂度:** Small
- **需要用户介入:** 否 [D-06]
---
#### T10 — RP2040 `main.py` 改造 `facetrack()` 优先吃 ESP32 数据 [修订于 2026-04-17]
- **所属代码库:** **RP2040**
- **修订说明v1.1:** 本任务是本轮修订的核心,同时修复 3 个问题:
- **BLOCKER #1:** 遵照新决策 D-07idle 状态下**不驱动舵机追踪**(保留 RP2040 原 `facetrack()` L45 的 `if current_state != "idle"` 语义。ESP32 侧按 D-03 始终发送坐标——RP2040 侧消费坐标但在 idle 下不调用 `set_target()`。理由idle 时眼睑闭合LID=30°追踪眼球在视觉上无意义且诡异。
- **BLOCKER #2:** 删除 `grove_active` / `grove_last_seen` 的重复更新——这部分已由 T09 负责。T10 只改数据源,不碰状态标志。**避免两处更新导致状态污染,使得未来无法根据 `grove_last_seen` 判断数据流来源**。
- **HIGH #3:** staticflag 去重现已在 T08 `parse_face()` 中完成T10 不再显式设置 `staticflag = False`。消费 `last_face_offset` 后清空即可。
- **需要修改文件:**
- `/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/main.py`L38-L54 `facetrack()` 函数)
- **Action:** 把 `facetrack()` 开头的数据源替换为以下结构(严格对齐 RP2040 现有 L45 的 idle 判断语义):
```python
def facetrack():
global yaw_countdown, yaw_target
# ① 数据消费:始终从 UART 队列中拿坐标,防止缓冲区溢出
# 优先使用 ESP32 发来的 last_face_offsetfallback 到 Grove
offset = external.last_face_offset
if offset is not None:
# 消费后立即清空,避免同一坐标被重复驱动
# 注意staticflag 已在 T08 parse_face 中更新,此处不再设置
external.last_face_offset = None
else:
offset = external.grove_read()
# grove_read 内部会自己更新 staticflag此处无需处理
# ② 状态更新grove_active / grove_last_seen 由 T09 在 incoming_commands
# 循环中统一更新T10 不再重复做。此处只处理"3 秒无数据回退"兜底:
now = time.ticks_ms()
if (animation.grove_active and
time.ticks_diff(now, animation.grove_last_seen) > 3000):
animation.grove_active = False
# ③ idle 状态下不驱动舵机D-07 决策)
# eyl/eyr/pit/yaw 都保持当前 target 不变,由随机动画接管
if animation.current_state == "idle":
return
# ④ 非 idle 状态:保持原有追踪逻辑完全不变
# (以下为 RP2040 现有代码,一字不改)
if offset:
x0, y0 = offset
x = animation.servos["EYL"].target + x0 * x_scale
... # 原 facetrack L53 之后的所有逻辑保持不变
```
**关键对齐点(必须严格遵守):**
1. **先消费 `last_face_offset` 再判断 idle** —— 否则数据会堆积在 coms 层,未来恢复到非 idle 时一次性冲出旧数据;
2. **idle 下仍然执行"3 秒无数据回退" (`grove_active = False`)** —— 保证语音唤醒后切到 listening 时,若此时人已离开,不会基于过期数据驱动舵机;
3. **staticflag 不在本任务设置** —— 由 T08 `parse_face()` 和 Grove `grove_read()` 各自管理;
4. **`grove_active` / `grove_last_seen` 不在本任务主动 set True** —— T09 在收到 face 消息时已经做了。
- **DoD:**
- 手动测试 1非 idle 追踪):
- Thonny: `animation.current_state = "listening"; external.last_face_offset = (50, 0); facetrack()` 后 `animation.servos["EYL"].target` 应有 `+5` 左右的变化x_scale=10/110≈0.0909 * 50 ≈ 4.5
- 手动测试 2idle 不追踪 - D-07:
- `animation.current_state = "idle"; external.last_face_offset = (50, 0); facetrack()`
- 先记录 `EYL_target_before = animation.servos["EYL"].target`
- 执行后 `animation.servos["EYL"].target == EYL_target_before`(未被修改)
- 但 `external.last_face_offset` 应已被置为 None数据被消费
- 手动测试 33 秒超时):
- `animation.grove_active = True; animation.grove_last_seen = time.ticks_ms() - 4000; external.last_face_offset = None; facetrack()`
- 执行后 `animation.grove_active == False`
- 手动测试 4staticflag 联动):
- 连续调用:`external.parse_face('face:50,0')` → `external.last_face_offset = (50,0)` → `facetrack()`
- 再次:`external.parse_face('face:51,1')` → staticflag 在 T08 内被设为 True → `external.last_face_offset = (51,1)` → `facetrack()` 时 `if not static:` 判定为 True即 static→ 不驱动 → 眼球稳定不漂移
- **复杂度:** Small
- **需要用户介入:** 否 [D-03][D-06][D-07]
- **Risk:** 若 T08 的 `FACE_STATIC_THRESHOLD=3` 实测过松(真实微动被误判为 static眼球不跟手过紧bbox 抖动被判为移动眼球抽搐。T12 需要实测调校。
---
### Group E集成、启动接线、验证与性能调优
---
#### T11 — `application.cc` 接入 `face_tracker_start()`
- **所属代码库:** ESP32
- **需要修改文件:**
- `main/application.cc`
- **Action:**
1. 在 `application.cc` 顶部 `#include` 区加:
```cpp
#include "face_tracker.h"
```
2. 在 `Application::Start()` 末尾Protocol 启动之后、`SetDeviceState(kDeviceStateIdle)` 之前合适位置,约 L544 之后)添加:
```cpp
// 启动人脸检测任务Kconfig 关闭时为空实现)
face_tracker_start();
```
3. 在 `Application::~Application()` 或合适的 shutdown 路径加 `face_tracker_stop();`(非必需但干净)。
- **DoD:**
- 启动日志看到 `face_tracker` 任务的第一条消息;
- Kconfig 关闭 `CONFIG_XIAOZHI_ENABLE_FACE_TRACKING` 重新编译后,启动日志**不再**出现 face_tracker 任何日志——验证 Kconfig 守卫生效;
- 启动后 5 秒内至少看到一次 `face stats: hit=? miss=?` 统计日志(确认任务真正在跑)。
- **复杂度:** Small
- **需要用户介入:** 否 [D-03]
---
#### T12 — 端到端联调:硬件飞线确认 + 两端同时刷新
- **所属代码库:** ESP32 + RP2040联合
- **需要修改文件:** 无代码改动,纯联调步骤。
- **Action:**
1. 硬件检查:
- ESP32 GPIO17 (TX) ↔ RP2040 GP5 (RX)
- ESP32 GPIO18 (RX) ↔ RP2040 GP4 (TX)
- 共地
2. 两端刷最新固件:
- ESP32`idf.py flash monitor`
- RP2040Thonny 上传 `coms.py` + `main.py` 后软重启
3. **idle 阶段测试D-07 验证)**:
- 启动后不说话,保持 idle 状态
- 把摄像头对准自己的脸
- 观察 ESP32 monitor应看到 `face score=... offset=(...)`ESP32 仍在检测并发送——D-03
- 观察 RP2040 Thonny应看到 `external.last_face_offset` 被刷新,但**眼球不动**(闭眼 + 不追踪——D-07
- 证实 idle 下只更新状态、不驱动舵机
4. **非 idle 追踪测试**:
- 说 "你好小智" 唤醒 → 进入 listening
- 把摄像头对准人脸,观察眼球开始追踪
- 快速左右晃脸 → 眼球实时跟随(延迟 < 200ms
- 眼球先动2 秒后 YAW 跟进
5. **无脸超时测试**:
- listening 状态下,用手挡住摄像头 3+ 秒
- RP2040: `grove_active` 应切为 False眼球切回随机动画
6. **static 去抖测试**:
- 在摄像头前保持不动 10 秒
- 眼球应**稳定不漂移**T08 static 去重生效)
- 若发现眼球持续朝一个方向漂移 → T08 的 `FACE_STATIC_THRESHOLD` 需要调大
7. **MCP 拍照期间 face_track 行为测试**HIGH #2 验证):
- listening 状态下同时触发 MCP `take_photo`
- ESP32 monitor: face_tracker 应在 `Capture()` 执行期间**跳帧而非卡死**
- 肉眼观察:眼球追踪可能会暂停 1-3 秒,但之后自动恢复,且无 panic/reset
- **DoD:**
- idle 状态: ESP32 发坐标, RP2040 收到但眼球静止D-07 ✅)
- listening: 人脸在镜头中心 → 眼球接近中位±20 deadzone 内)
- listening: 人向左移动 → 眼球EYL/EYR左偏2s 后 YAW 左偏
- 遮挡 3s+ 后眼球回归随机动画
- 人静止 10s 眼球不漂移static 去重 ✅)
- MCP 拍照期间 face_track 优雅降级(跳帧)
- **复杂度:** Medium
- **需要用户介入:** **是** — 纯硬件测试Claude 做不了
---
#### T13 — 性能量测与调优推理延迟、FPS、PSRAM、音频是否卡顿
- **所属代码库:** ESP32
- **需要修改文件:** 视结果可能修改 `Kconfig` 默认值或任务优先级;大概率零代码改动。
- **Action:**
1. `face_tracker.cc` 启动时已经在 T06 里打印了 PSRAM 余量。
2. 启动 WebSocket 对话:`"你好小智"` → 唤醒 → 连续说 30s同时镜头对准人脸。
3. 观察:
- 串口 Opus 解码日志是否出现 `queue full` 或超过 25ms 的间隔
- monitor 里 `face stats: fps≈X` 是否 ≥ 5
- `infer=` 平均值是否 < 100ms
4. 若 FPS < 5 或音频出现卡顿:
- 降低 `CONFIG_XIAOZHI_FACE_TRACKING_FPS` 到 5
- 若仍卡顿 → 将 face_track 任务优先级从 2 降到 1保留 decision point见章节 3
- 若推理时间过长(>150ms → RESEARCH Pitfall 4 的降级选项:切换到单阶段 PICO 模型(改 `new HumanFaceDetect()` 构造参数,**需用户确认**
- **DoD:**
- 填写**性能报告段**`docs/phase-01-face-tracking/PERF-NOTES.md`(新文件),包含:
- 平均推理耗时us
- 实际 FPS
- PSRAM 空闲 vs 使用
- 音频卡顿 Y/N + 证据
- MCP 拍照期间 face_track 跳帧情况HIGH #2 实测)
- 判定 ≥ 5 FPS、延迟 ≤ 200ms、音频无卡顿 → 通过;否则打开决策点 D-A见章节 3
- **复杂度:** Medium
- **需要用户介入:** **是** — 需要实际说话 + 对着摄像头
---
#### T14 — 关闭开关回归测试(`CONFIG_XIAOZHI_ENABLE_FACE_TRACKING=n`
- **所属代码库:** ESP32
- **需要修改文件:** 临时修改 `sdkconfig`(结束后复原)
- **Action:**
1. `idf.py menuconfig` → 关闭 `Enable ESP32 face tracking`
2. `idf.py build flash monitor`
3. 确认:
- 没有 `face_tracker` 相关日志
- 没有 `HumanFaceDetect` 构造
- 没有 `face:` UART 消息
- 现有 WebSocket 对话、LVGL 显示、`uart_send_string("listening")` 等一切正常
- RP2040 端退回使用 `grove_read()`(可能读不到数据,进入随机动画),但不崩溃
4. 测试完毕重新打开开关,回归默认 `y`。
- **DoD:**
- 开关 off 时整包二进制功能回到 Phase 开始前状态
- `idf.py size` 对比off 版本 flash 应比 on 版本小 ~250KB验证 rodata 模型不会被链接)
- **复杂度:** Small
- **需要用户介入:** 否(但需要用户本机实操)
---
#### T15 — 最终验收:对齐 GOAL.md 5 大成功标准
- **所属代码库:** ESP32 + RP2040联合
- **需要修改文件:** 创建 `docs/phase-01-face-tracking/ACCEPTANCE.md` 记录验收证据
- **Action:** 逐项走一遍 GOAL.md 的成功标准,见章节 7"完成标准"。每项记录 Pass/Fail + 证据(日志片段 / 视频链接 / 时间戳)。
- **DoD:** `ACCEPTANCE.md` 5 大标准全部 Pass可以 ship Phase 1。
- **复杂度:** Medium
- **需要用户介入:** **是** — 全手动验收
---
## 2. 依赖关系图Dependency Graph[修订于 2026-04-17]
**v1.1 修正:** 原版图中 `T05→T07` 是笔误。实际 T05 只是骨架任务(打印 hello不调用 `uart_send_face`;是 **T06** 才调用 `uart_send_face` 完成坐标推送。因此正确的依赖是 **T06 → T07**T07 必须在 T06 使用它之前完成)。
```
┌──────────────┐
│ T01 HW probe │ (阻塞全 Phase最小 V4L2 DQBUF/QBUF
└──────┬───────┘
┌───────────────┼───────────────┐
▼ ▼ ▼
┌──────────┐ ┌─────────────┐ ┌──────────┐
│ T02 Kcfg │ │ T03 deps │ │ T07 UART │ ← Group A/C 并行
│ 开关 │ │ esp-dl add │ │ send_face│
└────┬─────┘ └──────┬──────┘ └────┬─────┘
│ │ │
│ ▼ │
│ ┌─────────────┐ │
│ │ T04 Camera │ │
│ │ CaptureForDetection │
│ │ (双超时 mutex) │
│ └──────┬──────┘ │
│ ▼ │
│ ┌─────────────┐ │
└────────►│ T05 face_tracker 骨架│ │
│ (CMake 条件编译) │ │
└──────┬──────┘ │
▼ │
┌─────────────┐ │
│ T06 加入推理 │◄──────┘ ← T06 依赖 T07v1.1 修正)
│ + uart_send_face │
└──────┬──────┘
┌─────────────┐
│ T11 Start() │
└──────┬──────┘
(并行) ┌────────┐ │ ┌──────────┐
│ T08 │ │ │ T09 main │
│ parse │──────► │ ◄────────── │ commands │
│_face │ │ │ loop │
│ +static│ │ └──────────┘
└────────┘ │ │
│ ▼
│ ┌──────────┐
│ │ T10 face │
│ │ track改造│
│ │ (D-07) │
│ └──────────┘
▼ │
┌─────────────┐ │
│ T12 端到端 │◄──────────────────┘
└──────┬──────┘
┌─────────────┐
│ T13 性能调优 │
└──────┬──────┘
┌─────────────┐
│ T14 关开关 │
└──────┬──────┘
┌─────────────┐
│ T15 验收 │
└─────────────┘
```
### 关键并行性分析
| 阶段 | 可并行任务 |
|------|----------|
| T01 后 | T02、T03、T07、T08 四者**互相独立**,可同时开工 |
| T04 后 | T05 可开工 |
| T06 前置 | T02、T03、T04、T05、T07 全部完成后才能开工v1.1 修正) |
| T11 后 | T09、T10 可与 T11 并行(属于 RP2040 端),双端独立 |
| T12 | **强串行**(联合调试必须两端都到位) |
**建议执行顺序:** T01 → {T02, T03, T07 并行} → T04 → T05 → T06 → T11RP2040 侧 T08 → T09 → T10 可与 ESP32 侧并行T12 → T13 → T14 → T15 严格串行。
---
## 3. 关键决策点Decision Points
需要用户实时介入的 fork
### D-A性能不达标时的取舍T13 结果决定)
**触发条件:** T13 测得 FPS < 5 或音频卡顿。
**三个可选方案(需用户选一):**
| 方案 | 代价 | 收益 |
|------|------|------|
| A. 降 FPS 到 5 | 追踪不跟手 | 最简单,保留两级模型精度 |
| B. 改用单阶段 PICO 模型 | 精度下降bbox 可能偏移 10-20px | 推理从 ~38ms → 降到 ~20ms但官方报告是 122ms 的 224×224 模型,需复核 |
| C. face_track 任务优先级降到 1 | 人在快速移动时丢帧 | 音频绝对平滑 |
默认建议 A → B → C 顺序。
### D-BYUYV 字节序不匹配T06 首测决定)
**触发条件:** T06 实测 `score` 持续 < 0.5 而肉眼能看到人脸清晰。
**可选:**
1. 把 `pix_type` 改为 `DL_IMAGE_PIX_TYPE_RGB565_LE`(需要 Esp32Camera 切换格式,见 `camera_set_config`
2. 手动做 YUYV→RGB565 转换不推荐RESEARCH Don't Hand-Roll 已警告)
### D-C`CaptureForDetection` 与 MCP `take_photo` 竞态T12 运行时验证)
**v1.1 说明:** 已通过 T04 的双超时 mutex 策略face_track 使用 10ms timeout 跳帧缓解。T12 步骤 7 显式测试该场景。若仍有问题:
**方案:** 加一个全局 `is_taking_photo_flag`face_track 检测到就跳过本帧。
### D-Dstatic 阈值调校T12 实测决定)
**触发条件:** T08 的 `FACE_STATIC_THRESHOLD = 3` 在 T12 实测中不合适。
**可选:**
- 眼球静止时仍漂移 → 增大阈值4, 5
- 人微动不响应 → 减小阈值2, 1
---
## 4. 验证方法Verification
### 单元测试(仅 RP2040 端可做)
- T08 `parse_face()` 有 8+ 条手动断言(基本解析 4 条 + static 去重 4 条,见 DoD
- T09/T10 无独立单测,靠 T12 集成测试 + T10 的 4 条手动测试用例
### 手动测试步骤(每任务 DoD 已分项列出)
关键全流程测试T12
1. **idle 阶段**: 不说话,摄像头对准脸 → ESP32 发坐标 + RP2040 收坐标但眼球闭眼静止D-07 验证)
2. **listening 阶段**: 说 "你好小智" 唤醒 → 眼球开始追踪
3. 快速左右晃脸 → 眼球实时跟随(延迟感觉 < 200ms
4. 遮挡 3s → 眼球停止追踪,切随机动画
5. 静止 10s → 眼球不漂移static 去重)
6. MCP 拍照期间 → face_track 跳帧,拍照完后恢复
### 日志观察点
| 日志前缀 | 含义 | 来源 |
|---------|------|------|
| `FaceTracker` | 任务启动、10s 统计、PSRAM 量 | `face_tracker.cc` |
| `face score=` | 单次检测结果ESP_LOGD需开 DEBUG 级别) | T06 |
| `HumanFaceDetect` | esp-dl 初始化报错 | esp-dl 内部 |
| `T01_Probe` | V4L2 probe 结果 | T01 临时代码 |
| Thonny `print()` | RP2040 侧接收确认 | T09/T10 |
### 性能度量
- **推理延迟:** 代码内置 `esp_timer_get_time()` 前后对比T06 打 DEBUG 日志
- **实际发送 FPS:** T06 的 10 秒统计
- **UART 延迟:** 用逻辑分析仪夹 GPIO17 观察 face: 消息间隔;或用 RP2040 `time.ticks_ms()` 对比
- **PSRAM 占用:** T06 启动时 `heap_caps_get_info(MALLOC_CAP_SPIRAM)`
---
## 5. 回滚策略Rollback
### 5.1 单任务失败回滚
所有任务都是**单 commit 覆盖**,失败时 `git reset --hard HEAD~1` 即可。
按任务分组:
- T01 probe 代码:完成即删除,不入 commit临时 `ProbeFrameCapture` API 可保留也可删除)
- T02-T07, T11-T14各一个 commit
- T08-T10RP2040 侧**独立 commit**(在 RP2040 项目的 git 仓库里,如有)
- T15 验收不改代码,仅新增 `ACCEPTANCE.md`
### 5.2 Phase 级回滚(最糟情况:整个 Phase 不可行)
**触发条件:** T13 之后确认无论怎么调都会导致音频卡顿。
**回滚步骤:**
1. `idf.py menuconfig` 关闭 `CONFIG_XIAOZHI_ENABLE_FACE_TRACKING`
2. 功能代码保留(空壳),等待 Phase 2 优化或硬件升级
3. RP2040 侧 `main.py` 的 `facetrack()` 因为 `external.last_face_offset = None` 会自动 fallback 到 `grove_read()`,用户重装 Grove 即可继续旧方案
### 5.3 默认开关为 `y` 的兼容性测试(即 T14
因为默认 `y` 会让所有 `CogNog V1.0` 出厂设备直接启用人脸检测,**必须在 T14 验证 `n` 时向后兼容**(即没有 face_tracker 的版本应该和 Phase 1 之前行为一模一样)。这是 Phase Exit Criteria 的一部分。
---
## 6. 风险登记Risk Register
| ID | 风险 | 概率 | 影响 | 缓解措施 | 触发检测点 |
|----|------|------|------|----------|-----------|
| R1 | 音频对话卡顿(人脸检测任务抢占 Core 0 | 中 | 高 | 优先级=2 ( audio=8 ), Core 0 锁定, esp-dl 双核自动调度, 主动 10 FPS 限频;兜底方案 D-A | T13 |
| R2 | PSRAM OOMLVGL + 帧 + esp-dl 挤爆 8MB | 低 | 高 | T06 启动日志打印 `MALLOC_CAP_SPIRAM` 余量;预算 2-3MB 占用,余量 5MB+;降级 PICO 单模型 | T06 启动 |
| R3 | OV3660 + xiaozhi 兼容性 issue #1588 | 低-中 | 致命 | T01 最小 V4L2 probe修订后准确定位到 V4L2 层) | T01 |
| R4 | UART 协议冲突(`face:` 前缀误识别) | 低 | 中 | 现有 state_map/action_map 全是单词无冒号设计上隔离T08 DoD 断言覆盖 | T08/T09 |
| R5 | YUYV 字节序与 esp-dl 不匹配A1 | 中 | 中 | T06 首测观察 score<0.5 时切 RGB565 (决策点 D-B) | T06 |
| R6 | DVP 只有 1 个 V4L2 缓冲,双消费者死锁 | 中 | 高 | T04 强制 `capture_mutex_` + `ReleaseDetectionFrame()` 配对回收MCP take_photo 在 mutex 保护下 | T12 运行时 |
| R7 | 默认开关=y 导致不装摄像头的板子出现异常 | 极低 | 中 | Kconfig `depends on IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4`CMakeLists 显式排除非 S3此外 face_tracker_task 内部 `dynamic_cast<Esp32Camera*>` 失败即退出 | T14 |
| R8 | RP2040 `main.py` 现有 grove_read() 在无 Grove 时阻塞 | 低 | 低 | `grove_read()` 已经是 non-blocking检查 any()),未连接只会一直返回 NoneT10 改造让 ESP32 数据优先 | T12 |
| R9 | `uart_send_string` 与 `uart_send_face` 并发撕包 (A3) | 中 | 中 | T07 加 `s_uart_tx_mutex` 全局互斥 | T13 实测 |
| R10 | esp-dl 3.2.0 版本在 component registry 下架或重命名 | 极低 | 中 | 锁死 `version: "==3.2.0"``dependencies.lock` 入 git | T03 |
| R11 | `idf.py build` 首次拉取 managed_components 超过 magit timeout | 低 | 低 | 重试;可预先本地 clone 为 override 组件 | T03 |
| R12 | RP2040 `parse_face` 性能瓶颈MicroPython 每帧 10 次 startswith | 极低 | 极低 | 10 FPS 下每秒 10 次字符串解析MicroPython 毫无压力 | - |
| **R13** | **【v1.1 新增】MCP take_photo 期间 face_track 饥饿Capture mutex 阻塞长达 1-3 秒)** | **中** | **中** | **T04 双超时 mutex 策略face_track 10ms timeout 跳帧,拍照用 portMAX_DELAYT12 步骤 7 实测验证** | **T12** |
| **R14** | **【v1.1 新增】人脸静止时眼球持续漂移staticflag 语义错误)** | **中** | **高** | **T08 `parse_face` 内置 static 去重,`FACE_STATIC_THRESHOLD=3` 经验值T12 静止 10s 测试验证D-D 决策点留调校空间** | **T12** |
| **R15** | **【v1.1 新增】idle 状态下驱动眼球造成视觉异常(闭眼状态追踪)** | **中** | **中** | **D-07 决策RP2040 侧在 idle 下只更新状态不驱动舵机T10 `if current_state == "idle": return`T12 步骤 3 显式验证** | **T12** |
---
## 7. 完成标准Phase Exit Criteria
对齐 GOAL.md 5 大成功标准,每条具体验证方式:
### 7.1 性能指标
| 要求 | 验证方式 | 目标值 |
|------|---------|--------|
| 摄像头帧率 ≥ 5 FPS | T06/T13 监控 `face stats: fps≈` | ≥ 5 |
| 人脸检测延迟 ≤ 200ms | T06 日志 `infer=XXus` | ≤ 200,000 µs |
| 坐标传输延迟 ≤ 50ms | UART 理论 ~1ms + RP2040 处理 <50ms手动估算 | ≤ 50ms |
### 7.2 功能正确性
| 要求 | 验证方式 |
|------|---------|
| 检测到脸时 ESP32 发坐标(所有状态) | T12 monitor + Thonny 双端日志对照 [D-03] |
| 非 idle 状态 RP2040 眼球+YAW 正确追踪 | T12 肉眼观察 |
| idle 状态 RP2040 不驱动眼球(收坐标但不响应) | T12 步骤 3 显式测试 [D-07] |
| 眼球先动 YAW 延迟跟随 | T12 肉眼观察 |
| 无脸 3s 自动回退随机动画 | T12 遮挡测试 [D-06] |
| 人静止 10s 眼球不漂移 | T12 static 去重测试 |
### 7.3 不破坏现有功能
| 要求 | 验证方式 |
|------|---------|
| 语音对话无卡顿 | T13 连续说 30s观察 Opus 日志 |
| 唤醒词生效 | T12/T13 说"你好小智"后进入 listening |
| LCD 显示正常 | T12 人工观察 |
| UART 现有状态指令工作 | T12/T13 触发 speaking/listeningThonny 看到 |
| MCP 拍照功能正常face_track 优雅跳帧) | T12 步骤 7 |
### 7.4 代码质量
| 要求 | 验证方式 |
|------|---------|
| 人脸检测任务 Core 0 | `vTaskList()` 或 `xTaskGetAffinity` 确认;代码里已写死 `xTaskCreatePinnedToCore(..., 0)` |
| PSRAM 不 OOM | T06 heap 报告 + T13 连续运行监测 |
| UART 向后兼容 | T14 关开关测试 |
| 中文注释齐全 | Code review |
| 非 S3 目标编译通过 | T05 DoD用 `idf.py -DIDF_TARGET=esp32 reconfigure build` 验证HIGH #1 |
### 7.5 可维护性
| 要求 | 验证方式 |
|------|---------|
| 有/无 Grove 两种模式自动切换 | T10 代码里 `last_face_offset` 优先,无则 fallback `grove_read()` |
| menuconfig 可关 | T02 + T14 |
---
## 8. 潜在 RESEARCH 补漏(给未来 Phase 留痕)
RESEARCH.md 基本完整,但以下建议后续补充(**不阻塞本 Phase**
1. **未覆盖的场景:** 多人脸时只取第一个是否符合"第一张检测到的脸"定义。`HumanFaceDetect::run()` 返回的 vector 排序依据score? area? index?RESEARCH 未明确,**建议 T06 首测时打印 results.size() 和全部 results观察排序规则**。若发现顺序不稳定,需在 T06 加手动 `std::max_element(results.begin(), results.end(), by_score)` 挑最高 score 的。
2. **未测试:** `param_copy=true` vs `false` 的精确内存数字。RESEARCH Pitfall 4 只给了范围T13 实测后建议在 PERF-NOTES.md 里补充精确值,便于未来 Phase 2 优化基线。
3. **未提及:** ESP32 掉电后 UART 断流期间RP2040 的 3s 超时机制是否在主板共电时也适用。建议 T12 故意热重启 ESP32观察 RP2040 是否 3s 后自动切换到随机动画(应该会,但需确认)。
4. **【v1.1 新增】** D-07 对 UX 的影响idle 状态下虽然不驱动舵机,但 ESP32 仍持续消耗 Core 0 资源做人脸检测(按 D-03。未来若需极致省电可考虑 idle 下降采样(如 2 FPS 而非 10 FPSPhase 2 再议。
---
## 9. 总览:任务清单快速参考
| # | 任务 | 组 | 代码库 | 复杂度 | 需用户 | 估时 | v1.1 修订 |
|---|------|----|---------|--------|--------|-----|----------|
| T01 | 摄像头最小 V4L2 probe | A | ESP32 | S | 是 | 0.5h | **是** (BLOCKER #3) |
| T02 | Kconfig 开关 + FPS | A | ESP32 | S | 否 | 0.5h | - |
| T03 | esp-dl 依赖 | A | ESP32 | S | 否 | 0.5h | - |
| T04 | Camera CaptureForDetection + 双超时 mutex | B | ESP32 | M | 否 | 2h | **是** (HIGH #2) |
| T05 | face_tracker 骨架 + CMake 条件编译 | B | ESP32 | S | 否 | 1h | **是** (HIGH #1) |
| T06 | 集成 HumanFaceDetect | B | ESP32 | M | 是 | 2-3h | - |
| T07 | uart_send_face + mutex | C | ESP32 | S | 否 | 0.5h | - |
| T08 | parse_face + static 去重 | D | RP2040 | S | 否 | 0.5h | **是** (BLOCKER #2) |
| T09 | main.py 循环识别 | D | RP2040 | S | 否 | 0.5h | - |
| T10 | facetrack() 改造 (D-07 + 不重复更新) | D | RP2040 | S | 否 | 1h | **是** (BLOCKER #1) |
| T11 | application.cc 接线 | E | ESP32 | S | 否 | 0.5h | - |
| T12 | 端到端联调 | E | 联合 | M | 是 | 2h | 补充 idle/static/MCP 测试 |
| T13 | 性能调优 | E | ESP32 | M | 是 | 3h | - |
| T14 | 关开关回归 | E | ESP32 | S | 否 | 1h | - |
| T15 | 最终验收 | E | 联合 | M | 是 | 2h | - |
**总计:** 15 任务 · 约 **17-20h** Claude 执行 + **~5h** 用户实机
---
## 10. 交付物清单
本 Phase 完成时,以下文件应该存在:
**新增ESP32:**
- `main/face_tracker.h`
- `main/face_tracker.cc`
**修改ESP32:**
- `main/idf_component.yml` — 新增 2 个依赖
- `main/CMakeLists.txt` — 新增 1 行 source + 非 S3 排除逻辑v1.1
- `main/Kconfig.projbuild` — 新增 Kconfig 开关 + FPS choice`depends on IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4`
- `main/uart_component.{h,cc}` — 新增 `uart_send_face` + mutex
- `main/boards/common/esp32_camera.{h,cc}` — 新增 `CaptureForDetection` + `ReleaseDetectionFrame` + 双超时 mutexv1.1
- `main/application.cc` — 启动调用 `face_tracker_start()`
- `dependencies.lock` — 自动更新
**修改RP2040:**
- `/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/coms.py` — `parse_face` + `last_face_offset` + static 去重v1.1
- `/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/main.py` — 循环识别 + `facetrack()` 改造(含 D-07 idle 判断v1.1
**新增(文档):**
- `docs/phase-01-face-tracking/PERF-NOTES.md`T13 产出)
- `docs/phase-01-face-tracking/ACCEPTANCE.md`T15 产出)
**不应修改:**
- 分区表 `partitions/v2/16m.csv`(因选 rodata 部署)[D-01]
- 其他任何 board 目录下的代码
- LVGL / 音频 / WebSocket / WiFi / MQTT 相关模块