# 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-07（idle 状态 RP2040 不驱动眼球）
**v1.1 依赖图修正:** T06→T07（T07 被 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) |
| 估算总工时 | **16–22 小时** Claude 执行 + **3–5 小时** 用户实机验证 |
| 关键依赖 | 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 FPS，Kconfig 可配 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 probe（Phase 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 阶段：先打印 hello，T06 再加推理
           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 C：ESP32 端 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 D：RP2040 端协议识别

---

#### 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 置为 True，facetrack() 中 `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-07，idle 状态下**不驱动舵机追踪**（保留 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_offset，fallback 到 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）
  - 手动测试 2（idle 不追踪 - 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（数据被消费）
  - 手动测试 3（3 秒超时）:
    - `animation.grove_active = True; animation.grove_last_seen = time.ticks_ms() - 4000; external.last_face_offset = None; facetrack()`
    - 执行后 `animation.grove_active == False`
  - 手动测试 4（staticflag 联动）:
    - 连续调用：`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`
     - RP2040：Thonny 上传 `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 依赖 T07（v1.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 → T11；RP2040 侧 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-B：YUYV 字节序不匹配（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-D：static 阈值调校（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-T10：RP2040 侧**独立 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 OOM（LVGL + 帧 + 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()），未连接只会一直返回 None；T10 改造让 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_DELAY；T12 步骤 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/listening，Thonny 看到 |
| 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 FPS），Phase 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` + 双超时 mutex（v1.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 相关模块
