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

60 KiB
Raw Permalink Blame History

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 rodatahuman_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 用):
      // 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() 之后)临时插入:
      // 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:
    # 在 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_FPSESP32-C3/C6 等目标上该选项不可见。
  • 复杂度: Small
  • 需要用户介入: 否 [D-02][D-05]

T03 — 新增 esp-dl 与 human_face_detect 依赖

  • 所属代码库: ESP32
  • 需要修改文件:
    • main/idf_component.yml
  • Action:dependencies: 下新增两行(与 espressif/esp_video 同缩进):
      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]
    
    执行:
    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 reconfigureCan'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.hEsp32Camera 类内新增(包含 #include <mutex>#include <freertos/semphr.h>
      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 段新增:
      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()
      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 文件调用):
      #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 都在守卫块内:
        #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 的处理):
      # 在 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) 守卫内):
      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 新增(声明末尾):
      // 发送人脸检测坐标,格式:"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
      • 新增:
        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__ 末尾新增:
      # 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. 在类内新增方法:
      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 里手动调用:
      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 循环改为:
    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.pyL38-L54 facetrack() 函数)
  • Action:facetrack() 开头的数据源替换为以下结构(严格对齐 RP2040 现有 L45 的 idle 判断语义):

    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 区加:
      #include "face_tracker.h"
      
    2. Application::Start() 末尾Protocol 启动之后、SetDeviceState(kDeviceStateIdle) 之前合适位置,约 L544 之后)添加:
      // 启动人脸检测任务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. 两端刷最新固件:
      • ESP32idf.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 → T07T07 必须在 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-CCaptureForDetection 与 MCP take_photo 竞态T12 运行时验证)

v1.1 说明: 已通过 T04 的双超时 mutex 策略face_track 使用 10ms timeout 跳帧缓解。T12 步骤 7 显式测试该场景。若仍有问题:

方案: 加一个全局 is_taking_photo_flagface_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.pyfacetrack() 因为 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
R8 RP2040 main.py 现有 grove_read() 在无 Grove 时阻塞 grove_read() 已经是 non-blocking检查 any()),未连接只会一直返回 NoneT10 改造让 ESP32 数据优先 T12
R9 uart_send_stringuart_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": returnT12 步骤 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 DoDidf.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 choicedepends 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.pyparse_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.mdT13 产出)
  • docs/phase-01-face-tracking/ACCEPTANCE.mdT15 产出)

不应修改:

  • 分区表 partitions/v2/16m.csv(因选 rodata 部署)[D-01]
  • 其他任何 board 目录下的代码
  • LVGL / 音频 / WebSocket / WiFi / MQTT 相关模块