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