# 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(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 ` 和 `#include `): ```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 #include #include #include 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(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:,\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 ` 和一个 `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` 失败即退出 | 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 相关模块