实现 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>
60 KiB
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:
- 在
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; } - 在
Application::Start()末尾(protocol_->Start()之后)临时插入:// 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"); } idf.py build flash monitor,观察启动日志:应看到Probe 成功: bytesused=>0 elapsed<200000us(< 200ms)。- 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_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同缩进):
执行: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 reconfiguremanaged_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.hmain/boards/common/esp32_camera.cc
- Action:
- 在
esp32_camera.h的Esp32Camera类内新增(包含#include <mutex>和#include <freertos/semphr.h>):
并在 private 段新增: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);SemaphoreHandle_t capture_mutex_ = nullptr; // 用 FreeRTOS 信号量而非 std::mutex // 因为需要 timeout 语义(std::mutex 无 timed_lock on ESP32 toolchain 可移植) - 构造函数里创建:
capture_mutex_ = xSemaphoreCreateMutex(); - 在
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; } - 修改现有
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 会禁止显示)也不会因 includehuman_face_detect.hpp失败而编译失败。 - 需要创建文件:
main/face_tracker.hmain/face_tracker.cc
- 需要修改文件:
main/CMakeLists.txt(条件编译源文件)
- Action:
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 } #endifface_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 阶段:先打印 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
- 文件顶部用平台+功能双重守卫,所有 esp-dl 相关 include 都在守卫块内:
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 目标构建成功。
- 代码层面:
- 明确列出目标兼容性:
目标 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:
- 替换 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); } - 坐标映射公式必须严格与 RESEARCH Pitfall 7 一致(
cx * 224 / width - 112),否则 RP2040 端deadzone=20, x_adj_factor=10会失准。
- 替换 T05 骨架中的
- 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.hmain/uart_component.cc
- Action:
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);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 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:
- 在
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 - 在类内新增方法:
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 里手动调用:
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
- Thonny/REPL 里手动调用:
- 复杂度: 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 = datagrove_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'。
- 用 Thonny 在 REPL 模拟:
- 复杂度: 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后清空即可。
- BLOCKER #1: 遵照新决策 D-07,idle 状态下不驱动舵机追踪(保留 RP2040 原
-
需要修改文件:
/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/main.py(L38-L54facetrack()函数)
-
Action: 把
facetrack()开头的数据源替换为以下结构(严格对齐 RP2040 现有 L45 的 idle 判断语义):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 之后的所有逻辑保持不变关键对齐点(必须严格遵守):
- 先消费
last_face_offset再判断 idle —— 否则数据会堆积在 coms 层,未来恢复到非 idle 时一次性冲出旧数据; - idle 下仍然执行"3 秒无数据回退" (
grove_active = False) —— 保证语音唤醒后切到 listening 时,若此时人已离开,不会基于过期数据驱动舵机; - staticflag 不在本任务设置 —— 由 T08
parse_face()和 Grovegrove_read()各自管理; 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)
- Thonny:
- 手动测试 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)→ 不驱动 → 眼球稳定不漂移
- 连续调用:
- 手动测试 1(非 idle 追踪):
-
复杂度: 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:
- 在
application.cc顶部#include区加:#include "face_tracker.h" - 在
Application::Start()末尾(Protocol 启动之后、SetDeviceState(kDeviceStateIdle)之前合适位置,约 L544 之后)添加:// 启动人脸检测任务(Kconfig 关闭时为空实现) face_tracker_start(); - 在
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:
- 硬件检查:
- ESP32 GPIO17 (TX) ↔ RP2040 GP5 (RX)
- ESP32 GPIO18 (RX) ↔ RP2040 GP4 (TX)
- 共地
- 两端刷最新固件:
- ESP32:
idf.py flash monitor - RP2040:Thonny 上传
coms.py+main.py后软重启
- ESP32:
- idle 阶段测试(D-07 验证):
- 启动后不说话,保持 idle 状态
- 把摄像头对准自己的脸
- 观察 ESP32 monitor:应看到
face score=... offset=(...)(ESP32 仍在检测并发送——D-03) - 观察 RP2040 Thonny:应看到
external.last_face_offset被刷新,但眼球不动(闭眼 + 不追踪——D-07) - 证实 idle 下只更新状态、不驱动舵机
- 非 idle 追踪测试:
- 说 "你好小智" 唤醒 → 进入 listening
- 把摄像头对准人脸,观察眼球开始追踪
- 快速左右晃脸 → 眼球实时跟随(延迟 < 200ms)
- 眼球先动,2 秒后 YAW 跟进
- 无脸超时测试:
- listening 状态下,用手挡住摄像头 3+ 秒
- RP2040:
grove_active应切为 False,眼球切回随机动画
- static 去抖测试:
- 在摄像头前保持不动 10 秒
- 眼球应稳定不漂移(T08 static 去重生效)
- 若发现眼球持续朝一个方向漂移 → T08 的
FACE_STATIC_THRESHOLD需要调大
- MCP 拍照期间 face_track 行为测试(HIGH #2 验证):
- listening 状态下同时触发 MCP
take_photo - ESP32 monitor: face_tracker 应在
Capture()执行期间跳帧而非卡死 - 肉眼观察:眼球追踪可能会暂停 1-3 秒,但之后自动恢复,且无 panic/reset
- listening 状态下同时触发 MCP
- 硬件检查:
- 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:
face_tracker.cc启动时已经在 T06 里打印了 PSRAM 余量。- 启动 WebSocket 对话:
"你好小智"→ 唤醒 → 连续说 30s,同时镜头对准人脸。 - 观察:
- 串口 Opus 解码日志是否出现
queue full或超过 25ms 的间隔 - monitor 里
face stats: fps≈X是否 ≥ 5 infer=平均值是否 < 100ms
- 串口 Opus 解码日志是否出现
- 若 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:
idf.py menuconfig→ 关闭Enable ESP32 face trackingidf.py build flash monitor- 确认:
- 没有
face_tracker相关日志 - 没有
HumanFaceDetect构造 - 没有
face:UART 消息 - 现有 WebSocket 对话、LVGL 显示、
uart_send_string("listening")等一切正常 - RP2040 端退回使用
grove_read()(可能读不到数据,进入随机动画),但不崩溃
- 没有
- 测试完毕重新打开开关,回归默认
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.md5 大标准全部 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 而肉眼能看到人脸清晰。
可选:
- 把
pix_type改为DL_IMAGE_PIX_TYPE_RGB565_LE(需要 Esp32Camera 切换格式,见camera_set_config) - 手动做 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):
- idle 阶段: 不说话,摄像头对准脸 → ESP32 发坐标 + RP2040 收坐标但眼球闭眼静止(D-07 验证)
- listening 阶段: 说 "你好小智" 唤醒 → 眼球开始追踪
- 快速左右晃脸 → 眼球实时跟随(延迟感觉 < 200ms)
- 遮挡 3s → 眼球停止追踪,切随机动画
- 静止 10s → 眼球不漂移(static 去重)
- 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(临时
ProbeFrameCaptureAPI 可保留也可删除) - T02-T07, T11-T14:各一个 commit
- T08-T10:RP2040 侧独立 commit(在 RP2040 项目的 git 仓库里,如有)
- T15 验收不改代码,仅新增
ACCEPTANCE.md
5.2 Phase 级回滚(最糟情况:整个 Phase 不可行)
触发条件: T13 之后确认无论怎么调都会导致音频卡顿。
回滚步骤:
idf.py menuconfig关闭CONFIG_XIAOZHI_ENABLE_FACE_TRACKING- 功能代码保留(空壳),等待 Phase 2 优化或硬件升级
- 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 | |
| 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):
-
未覆盖的场景: 多人脸时只取第一个是否符合"第一张检测到的脸"定义。
HumanFaceDetect::run()返回的 vector 排序依据(score? area? index?)RESEARCH 未明确,建议 T06 首测时打印 results.size() 和全部 results,观察排序规则。若发现顺序不稳定,需在 T06 加手动std::max_element(results.begin(), results.end(), by_score)挑最高 score 的。 -
未测试:
param_copy=truevsfalse的精确内存数字。RESEARCH Pitfall 4 只给了范围,T13 实测后建议在 PERF-NOTES.md 里补充精确值,便于未来 Phase 2 优化基线。 -
未提及: ESP32 掉电后 UART 断流期间,RP2040 的 3s 超时机制是否在主板共电时也适用。建议 T12 故意热重启 ESP32,观察 RP2040 是否 3s 后自动切换到随机动画(应该会,但需确认)。
-
【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.hmain/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+ mutexmain/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 相关模块