diff --git a/docs/phase-01-face-tracking/GOAL.md b/docs/phase-01-face-tracking/GOAL.md new file mode 100644 index 0000000..a7adcb2 --- /dev/null +++ b/docs/phase-01-face-tracking/GOAL.md @@ -0,0 +1,92 @@ +# Phase 1: 单摄像头人脸追踪 + +## 目标 + +将 ESP32-S3 上的 OV3660 摄像头用作人脸追踪数据源,替代 Grove Vision AI V2 模块,驱动 RP2040 控制的眼球(EYL/EYR)和身体(YAW)舵机追踪人脸移动。 + +## 硬件环境 + +- **ESP32 模组**: ESP32-S3-WROOM-1-N16R8(16MB Flash + 8MB PSRAM) +- **摄像头**: OV3660 DVP 接口(已完成 3 根飞线:GPIO 35→14, 36→41, 37→42) +- **RP2040**: Raspberry Pi Pico(直接焊在 CogNog V1.0 PCB 上) +- **舵机**: 9 个 180° 标准舵机(KPower M0090 / MG90S 180°) +- **无 Grove Vision AI V2**: 本 Phase 的核心目的是省去此模块 + +## 当前架构(改造前) + +``` +OV3660 → ESP32-S3(仅显示/视觉辅助功能) +Grove Vision AI V2 → UART 921600 → RP2040 的 GP0/GP1 + ↓ + facetrack() 解析 boxes + ↓ + 驱动 EYL/EYR/PIT/YAW +``` + +## 目标架构(改造后) + +``` +OV3660 → ESP32-S3 + ├── 视觉辅助功能(保留) + └── 人脸检测推理(新增) + ↓ + 提取人脸中心 (x, y) 偏移 + ↓ + UART 115200 → RP2040 的 GP4/GP5 + ↓ + coms.py 识别 face: 协议,注入 facetrack() + ↓ + 驱动 EYL/EYR/PIT/YAW(复用现有逻辑) +``` + +## 成功标准(Success Criteria) + +必须同时满足: + +1. **性能指标** + - ESP32 摄像头帧率 ≥ 5 FPS(QVGA 320×240 或更低分辨率) + - 人脸检测延迟 ≤ 200ms + - 坐标传输延迟 ≤ 50ms + +2. **功能正确性** + - 检测到人脸时,ESP32 通过 UART 发送格式化坐标到 RP2040 + - RP2040 接收坐标后眼球和身体正确追踪人脸方向 + - 人脸偏离摄像头中心时,眼球先转动,YAW 延迟跟随(保留现有逻辑) + - 无人脸时,3 秒后 `grove_active` 标志自动置为 False,回退到随机动画 + +3. **不破坏现有功能** + - 语音对话(WebSocket + Opus 音频编解码)无卡顿、无断连 + - 唤醒词检测正常工作 + - LCD 显示(如有)正常刷新 + - 现有 UART 状态指令(`"idle"`, `"listening"`, `"speaking"` 等)继续工作 + +4. **代码质量** + - ESP32 端人脸检测任务运行在 Core 0,与音频/WiFi 隔离 + - PSRAM 合理使用,不出现 OOM + - UART 协议向后兼容(不影响 RP2040 现有状态指令解析) + - 所有新增代码有清晰的中文注释 + +5. **可维护性** + - 支持无 Grove 和有 Grove 两种模式自动切换(复用已有的 `grove_active` 机制) + - ESP32 侧面能通过 menuconfig 或宏定义开关人脸检测功能 + +## 涉及的代码库 + +- **ESP32 端**: `/Users/rdzleo/Desktop/CogletESP-camera-version`(当前目录) +- **RP2040 端**: `/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040`(另一个本地目录) + +> 注意:RP2040 侧的 `coms.py` 和 `main.py` 已经做过一次增强动画改造(2026-04-17)。本 Phase 需要在此基础上继续增加 ESP32 人脸坐标协议支持。 + +## 非目标(Out of Scope) + +- 不做人脸识别(谁的脸),只做人脸检测(有没有脸 + 在哪里) +- 不做多人追踪(只追踪第一张检测到的脸) +- 不实现 180° 全景追踪(保持原 Grove 方案的追踪范围) +- 不改变眼球/YAW 的追踪算法(复用 `main.py facetrack()` 中的现有逻辑) + +## 风险与限制 + +- **性能风险**: ESP32-S3 同时运行 WiFi + WebSocket + Opus + AI 对话 + LVGL + 摄像头 + 人脸检测,CPU 和内存压力大 +- **音频干扰风险**: 人脸检测占用的 Core 0 可能与音频共享,导致对话卡顿 +- **跨项目协调**: ESP32 和 RP2040 属于不同代码库,需协调 UART 协议 +- **模型精度**: esp-dl 的人脸检测模型精度低于 Grove Vision AI V2,追踪流畅度可能下降 diff --git a/docs/phase-01-face-tracking/PLAN.md b/docs/phase-01-face-tracking/PLAN.md new file mode 100644 index 0000000..cc0e0b7 --- /dev/null +++ b/docs/phase-01-face-tracking/PLAN.md @@ -0,0 +1,1200 @@ +# 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 相关模块 diff --git a/docs/phase-01-face-tracking/PLAN_CHECK.md b/docs/phase-01-face-tracking/PLAN_CHECK.md new file mode 100644 index 0000000..ccfe523 --- /dev/null +++ b/docs/phase-01-face-tracking/PLAN_CHECK.md @@ -0,0 +1,189 @@ +# Phase 1 PLAN.md 第二轮审查报告 + +**审查日期:** 2026-04-17 +**审查对象:** `/Users/rdzleo/Desktop/CogletESP-camera-version/docs/phase-01-face-tracking/PLAN.md` v1.1 +**审查方法:** 对照第一轮 PLAN_CHECK.md 每条问题逐一核验 + 实际代码交叉 +**审查人:** GSD Plan Checker(第二轮) + +--- + +## 1. 审查结论 + +**`PASS_WITH_NOTES`** + +第一轮提出的 3 个 BLOCKER + 3 个 HIGH 全部得到正确修复,修复质量整体良好。Revision History 完整、决策点 D-07 在"用户已决策"表中明确登记、依赖图笔误已订正、新增风险 R13/R14/R15 与代码逻辑一致。**可以进入执行阶段**。 + +仅有 2 处轻微注意点(不阻塞,仅作提醒),见第 3 节。 + +--- + +## 2. 原问题修复情况表 + +| 编号 | 严重级 | 原问题 | 是否修复 | 修复质量 | 备注 | +|------|--------|--------|----------|----------|------| +| BLOCKER #1 | 🔴 | T10 facetrack 缺失 idle 判断 | ✅ 已修复 | **好** | T10 修订(L667-734)+ D-07 决策(L48)+ R15 风险登记(L1078) | +| BLOCKER #2 | 🔴 | grove_active 重复更新 | ✅ 已修复 | **好** | T10 删除重复更新代码 + T09(L659)注释明确"只在此处更新" | +| BLOCKER #3 | 🔴 | T01 用 Capture() 过重 | ✅ 已修复 | **好** | T01 重写为最小 V4L2 DQBUF/QBUF probe,预算 < 200ms | +| HIGH #1 | 🟠 | CMakeLists 非 S3 目标 | ✅ 已修复 | **好** | 三重保护:Kconfig depends + CMake REMOVE_ITEM + 源文件 #if 守卫 | +| HIGH #2 | 🟠 | Capture mutex 饥饿 | ✅ 已修复 | **好** | T04 双超时策略(detection 10ms timeout 跳帧,capture portMAX_DELAY) | +| HIGH #3 | 🟠 | staticflag 硬编码 False 漂移 | ✅ 已修复 | **好** | T08 新增 `last_face_raw` + `FACE_STATIC_THRESHOLD=3` 去重逻辑 | +| 笔误 | — | 依赖图 T05→T07 | ✅ 已修复 | **好** | 改为 T06→T07,并在 v1.1 标头明确说明 | + +--- + +## 3. 修复细节核验 + +### BLOCKER #1(T10 idle 判断) + +- **D-07 决策已登记**(L48):明确"idle 状态下 RP2040 不驱动眼球舵机追踪。ESP32 侧行为不变(按 D-03 始终发送坐标);RP2040 侧收到 face:x,y 时,若 animation.current_state == 'idle',仅更新 grove_active/grove_last_seen/last_face_offset 状态,不调用 set_target()" +- **代码位置正确**(L699-702):idle 判断放在数据消费 + grove_active 超时之后、舵机驱动之前——这是**严格正确的位置**: + - 先消费 `last_face_offset`(避免数据堆积)✅ + - 再做 3s 超时回退(即便 idle 也要正确清 `grove_active`)✅ + - 最后 `if idle: return`,跳过 servo 驱动 ✅ +- **对齐原代码语义**:原 `facetrack()` L53 是 `if animation.current_state != "idle":`,T10 用 `if idle: return` 实现等价转换,且把 `grove_active` 超时判定上移到 idle return 之前——这反而比原代码更稳健(原代码 idle 时根本不更新 grove_active)。 +- **DoD 测试用例完整**(L717-730):手动测试 1(非 idle 追踪)、测试 2(idle 不追踪)、测试 3(3s 超时)、测试 4(staticflag 联动)四项覆盖。 + +### BLOCKER #2(grove_active 重复更新) + +- **T09 单一更新点**(L649-651):`animation.grove_active = True; animation.grove_last_seen = time.ticks_ms()` 只在 incoming_commands 循环里设置。 +- **T09 显式注释**(L659):"grove_active / grove_last_seen 的更新**只在此处**做,T10 的 facetrack() 不再重复更新"——意图清晰。 +- **T10 完全删除原 L456-461 重复代码**:T10 修订后只保留"3 秒无数据回退"兜底(L694-697),不再 set True。 +- **职责划分清晰**: + - T09:incoming_commands 收到 face: → set True + 更新 last_seen + - T10:facetrack 检查超时 → set False + - 两处不冲突,未来 `grove_last_seen` 真正反映"最后一次收到 ESP32 数据的时间"。 + +### BLOCKER #3(T01 最小 probe) + +- **改为 V4L2 直接 ioctl**(L75-99):只调一次 `VIDIOC_DQBUF` + `VIDIOC_QBUF`,不触发 JPEG 编码、不做 PSRAM 大分配、不触发 encoder_thread。 +- **执行时间预算合理**:DoD(L116)要求 `elapsed < 200ms`——这是合理的(V4L2 DQBUF 在 10 FPS 下理论 < 100ms 就能拿到下一帧;DVP 唤醒延迟也在此预算内)。 +- **probe 调用位置正确**(L101-112):放在 `Application::Start()` 末尾、`protocol_->Start()` 之后——此时 `Esp32Camera` 已构造完成,`streaming_on_=true`,video_fd_ 已 open。 +- **保留诊断 API**(L114):T04 完成后只删 probe 调用、保留 `ProbeFrameCapture` API 作为诊断工具——比原方案"完全删除"更稳。 + +### HIGH #1(CMakeLists 非 S3) + +- **三重保护明确写入** T05(L370-387 + L388-394 兼容性表): + 1. **Kconfig 层**:`depends on IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4`(T02 L139) + 2. **CMake 层**:`if(NOT CONFIG_IDF_TARGET_ESP32S3) list(REMOVE_ITEM SOURCES "face_tracker.cc") endif()`(T05 L380-382) + 3. **源文件层**:`#if defined(CONFIG_XIAOZHI_ENABLE_FACE_TRACKING) && defined(CONFIG_IDF_TARGET_ESP32S3)` 包裹所有 esp-dl include(T05 L319) +- **兼容性表**(L388-394)覆盖 ESP32-S3 / P4 / 原版 / C3 / C6 五种目标的预期行为。 +- **DoD 加入交叉验证**(L397):"ESP32(原版)编译通过(验证非 S3 目标不会因 human_face_detect.hpp 缺失而失败)"。 +- **轻微注意**:T05 L392 写"ESP32-P4:face_tracker.cc 编译为空壳"——但 T02 Kconfig L139 明确 `depends on IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4`,且 T05 L380 CMake 条件是 `if(NOT CONFIG_IDF_TARGET_ESP32S3)` —— 这意味着 P4 目标会被 CMake 排除掉编译(与 Kconfig 允许冲突)。**详见第 4 节 NOTE-1**。 + +### HIGH #2(Capture mutex 饥饿) + +- **T04 双超时策略明确**(L209,修订说明):"face_track 拿不到锁就跳过这一帧(人脸检测允许丢帧),拍照则可完整持有 mutex"。 +- **代码层面正确**: + - `CaptureForDetection`(L240):`xSemaphoreTake(capture_mutex_, pdMS_TO_TICKS(10))`,拿不到立即返回 false + - `Capture`(L275):`xSemaphoreTake(capture_mutex_, portMAX_DELAY)` 等任意时长 +- **使用 FreeRTOS Semaphore 而非 std::mutex**:注释(L231)解释了原因(std::mutex 的 `try_lock_for` 在 ESP32 toolchain 上不可移植)——**正确的工程权衡**。 +- **新增 R13 风险登记**(L1076):与代码逻辑完全一致。 +- **T12 步骤 7 显式验证**(L796-799):listening 状态下触发 MCP take_photo,观察 face_tracker 跳帧而非卡死。 +- **轻微注意**:`CaptureForDetection` 与 `ReleaseDetectionFrame` 的 mutex 是**跨调用持有**——`CaptureForDetection` 拿锁后不解锁,由后续的 `ReleaseDetectionFrame` 解锁。这是正确的(保护 mmap_buffers_[buf.index] 的内容直到 caller 用完),但 face_tracker_task 必须保证两者**严格成对**调用。**详见第 4 节 NOTE-2**。 + +### HIGH #3(staticflag 漂移) + +- **T08 新增 static 去重逻辑**(L592-606): + - 与 `last_face_raw` 比较 dx, dy + - 阈值 ≤ `FACE_STATIC_THRESHOLD=3` → 设 `staticflag = True` + - 否则更新 `last_face_raw` + 设 `staticflag = False` + - 首次收到坐标视为非静态(合理) +- **阈值 3 像素的合理性**: + - 在 224×224 归一化坐标下约 2.7% + - 对照 `coms.py` L60 `deadzone = 20`(更宽容),3 像素只用于**消除 bbox 抖动**,不会影响 deadzone 判定 + - 与原 Grove 的 `if boxes_part != self.last_boxes` 字符串完全相等比较(coms.py L80)相比,3 像素阈值更宽容(容忍 bbox 抖动),是**合理升级** +- **T10 不再硬编码 staticflag = False**(L686 注释明确):"注意:staticflag 已在 T08 parse_face 中更新,此处不再设置" +- **DoD 完整覆盖**(L618-628):基本解析 4 条 + static 去重 4 条断言。 +- **R14 风险登记 + D-D 决策点**(L1077, L989-992):留出实测调校空间。 + +### 依赖图笔误 + +- **L900-902 已改为 T06→T07**:图中 T07 的箭头指向 T06,配上注释 "← T06 依赖 T07(v1.1 修正)" +- **v1.1 修订说明明确**(L20, L873-875):"原版图中 T05→T07 是笔误。实际 T05 只是骨架任务(打印 hello),不调用 uart_send_face;是 T06 才调用 uart_send_face 完成坐标推送" +- **执行顺序表同步更新**(L946):"T06 前置:T02、T03、T04、T05、T07 全部完成后才能开工(v1.1 修正)" + +--- + +## 4. 新发现的问题 + +### 🔵 NOTE(提示性,不阻塞执行) + +#### NOTE-1: ESP32-P4 在 Kconfig 允许但 CMake 排除——存在矛盾 + +- **位置:** T02 L139(Kconfig `depends on IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4`) vs T05 L380(CMake `if(NOT CONFIG_IDF_TARGET_ESP32S3)`) +- **问题:** Kconfig 允许 P4 看到选项并打开,但 CMake 在 P4 上会移除 `face_tracker.cc`,导致 face_tracker 函数变成空壳(`#else` 分支的 3 个空函数)。如果用户在 P4 上启用该选项,编译能过但功能不工作——日志里也不会有任何报错。 +- **建议:** 二选一: + - **方案 A**:CMake 改为 `if(NOT CONFIG_IDF_TARGET_ESP32S3 AND NOT CONFIG_IDF_TARGET_ESP32P4)` 一并支持 P4 + - **方案 B**:Kconfig 改为 `depends on IDF_TARGET_ESP32S3` 只支持 S3,把 P4 从声明列表中去掉 +- **不阻塞**:本项目硬件是 S3-N16R8,P4 路径根本不会被走到。但作为面向其他用户的 Kconfig 开关,最好和实际 CMake 行为对齐。 + +#### NOTE-2: `CaptureForDetection` 与 `ReleaseDetectionFrame` 跨调用持锁——face_tracker 任务必须严格成对 + +- **位置:** T04 L256-258(注释"不解锁!由 ReleaseDetectionFrame 配对解锁"),T06 L443/458 调用配对 +- **观察:** T06 的实现已经正确做到了:在 `CaptureForDetection` 返回 true 后立即用 `auto& results = detector->run(img)`,然后立刻调用 `ReleaseDetectionFrame(f)`。中间没有 `continue`/`return`/异常路径会跳过 `ReleaseDetectionFrame`。 +- **潜在隐患:** 如果未来有人在 T06 的 if-else 分支中插入 early return(例如某种检测失败的快速路径),可能漏掉 `ReleaseDetectionFrame` → 整个 capture mutex 永久持有 → MCP 拍照永久卡死。 +- **建议:** T06 的 ReleaseDetectionFrame 调用建议用 RAII 风格包装: + ```cpp + // 可选改进:建一个 helper struct + struct DetectionFrameGuard { + Esp32Camera* cam; + Esp32Camera::FrameRef* f; + ~DetectionFrameGuard() { if (cam && f) cam->ReleaseDetectionFrame(*f); } + }; + ``` + 或者至少在 T06 的代码注释中加 **"绝对不要在 CaptureForDetection true 之后到 ReleaseDetectionFrame 之间插入 early return"** 的警示。 +- **不阻塞**:当前代码路径正确,仅作未来维护提醒。 + +--- + +## 5. 已修复但值得点赞的设计 + +1. **T10 idle return 之前先做 3 秒超时回退**(L694-702)——比原 `facetrack()` 在 idle 下完全不更新 grove_active 的行为**更稳健**。这意味着用户在 idle 下离开摄像头 3 秒后再唤醒到 listening,`grove_active=False` 已被正确清掉,不会出现"基于过期数据驱动眼球"的视觉异常。 +2. **T01 保留 `ProbeFrameCapture` 作为诊断 API**(L114)——比"完全删除"更工程化,未来 issue 排查可独立触发。 +3. **T05 三重保护**(Kconfig + CMake + 源文件 #if)——任一机制失效另两层兜底,鲁棒性极高。 +4. **T08 阈值 3 像素的工程经验值**——对应 coms.py L60 `deadzone=20`,比例合理;而且在 DoD 里给了完整断言(包括边界 = 阈值的 case)。 +5. **R13/R14/R15 与 D-D/D-07 双向链接**——风险登记和决策点形成闭环。 + +--- + +## 6. Revision History 完整性核验 + +- ✅ Revision History 表(L13-16)清晰列出 v1.0 → v1.1 的变更摘要 +- ✅ "v1.1 涉及修改的任务"明确列举(L18):T01、T04、T06、T08、T10 + - 注:T05/T07/T09 也有少量修订(CMake/注释),但属于配套调整,未单独标记 + - **轻微遗漏**:T05 实际有修订(CMake 条件编译策略,HIGH #1 修复),但 v1.1 摘要里没列入。建议下次修订时补全。 +- ✅ "v1.1 新增决策"(L19)明确列出 D-07 +- ✅ "v1.1 依赖图修正"(L20)明确指出 T06→T07 +- ✅ 每个修订任务都有 `[修订于 2026-04-17]` 标记:T01(L63)、T04(L206)、T05(L287)、T08(L549)、T10(L667)—— **T06 没有**[修订于 2026-04-17] 标记,但实际 T06 内容相对 v1.0 有微调(FPS 计算加保底防除零)。建议补上。 +- ✅ 任务编号保持 T01-T15 不变 +- ✅ 任务清单快速参考(L1148-1166)的 v1.1 修订列已正确标注 + +--- + +## 7. 推荐下一步 + +### ✅ 批准进入执行阶段 + +第一轮的 3 BLOCKER + 3 HIGH 已全部修复且修复质量良好;新增风险 R13/R14/R15 与代码逻辑一致;Revision History 基本完整;依赖图笔误已订正。 + +**可以执行 `/gsd-execute-phase 01-face-tracking`**。 + +### 执行过程中建议留意 + +1. **NOTE-1(P4 行为不一致)**:执行 T02/T05 时如果方便,顺手统一 Kconfig 和 CMake 的目标列表(建议改 Kconfig 为 `depends on IDF_TARGET_ESP32S3` 只支持 S3,因为本项目硬件就是 S3)。 +2. **NOTE-2(CaptureForDetection RAII)**:执行 T04/T06 时考虑加 RAII guard 或至少加警示注释,避免未来维护引入 mutex 永久持有的 bug。 +3. **R14(FACE_STATIC_THRESHOLD 调校)**:T12 阶段必须实测验证 3 像素阈值是否合适,准备好按 D-D 调整。 +4. **D-07 用户体验观察**:T12 步骤 3 显式测试 idle 状态——这是本次修订的核心,务必拍视频/截图记录眼球闭眼且不动的状态作为 ACCEPTANCE.md 证据。 + +### 不阻塞但可优化(执行后再议) + +- 给 T05 / T06 补 [修订于 2026-04-17] 标记(Revision History 完整性) +- T05 兼容性表对 P4 行为表述对齐 NOTE-1 的修订 + +--- + +## 8. 审查总结 + +- **正面:** 第一轮提出的所有问题都得到准确理解和修复;修复方案不仅"满足要求"还体现了工程权衡(如 FreeRTOS Semaphore vs std::mutex、RAII 担心、保留诊断 API);新增决策 D-07 的设计深入到了"idle 时眼睑闭合所以追踪无意义"的产品语义层面,不是机械修复。 +- **负面:** Revision History 标记不完全(T05/T06 缺标记),少量 Kconfig/CMake 目标列表不一致——但都属于轻微注意,不影响执行。 +- **结论:** 修订质量超出预期,**PASS_WITH_NOTES**,进入执行阶段。 diff --git a/docs/phase-01-face-tracking/PROGRESS.md b/docs/phase-01-face-tracking/PROGRESS.md new file mode 100644 index 0000000..404459a --- /dev/null +++ b/docs/phase-01-face-tracking/PROGRESS.md @@ -0,0 +1,109 @@ +# Phase 01 执行进度追踪 + +> 由于本仓库非 git 仓库,用本文件替代 commit 作为原子进度追踪。 +> 每完成一个任务追加一行;遇到偏差记录 `[!]` 条目。 + +## 任务状态表 + +- [~] T01 摄像头硬件 V4L2 probe —— 代码完成,硬件验证待用户 +- [x] T02 Kconfig 开关 + FPS choice +- [x] T03 esp-dl + human_face_detect 依赖 +- [x] T04 Esp32Camera CaptureForDetection + 双超时 mutex +- [x] T05 face_tracker.{h,cc} 骨架 + CMake 条件编译 +- [x] T06 集成 HumanFaceDetect 推理 + 坐标归一化(代码部分;实测待 T12) +- [x] T07 uart_send_face + uart mutex +- [ ] T08 RP2040 parse_face + static 去重 +- [ ] T09 RP2040 main.py incoming_commands 识别 face: +- [ ] T10 RP2040 facetrack() 改造(D-07 idle return) +- [ ] T11 application.cc 接入 face_tracker_start +- [ ] T12 端到端联调 +- [ ] T13 性能调优 +- [ ] T14 关开关回归测试 +- [ ] T15 最终验收 + +## 执行日志 + +- [x] T01 代码部分完成:2026-04-17 + - 新增 `ProbeFrameCapture()` 到 `main/boards/common/esp32_camera.{h,cc}` + - 在 `main/application.cc` 的 `Start()` 末尾插入 probe 调用(`#ifndef CONFIG_IDF_TARGET_ESP32` 守卫) + - 硬件验证部分待用户接 USB 后在 T02/T03 通过后烧录验证 + +- [x] T02 完成:2026-04-17 + - 在 `main/Kconfig.projbuild` 的 Camera Configuration menu 末尾新增 + `XIAOZHI_ENABLE_FACE_TRACKING` + FPS choice(5/10/15) + - 采用 PLAN_CHECK NOTE-1 方案 B:`depends on IDF_TARGET_ESP32S3` + 只支持 S3,与 CMake 排除逻辑对齐 + +- [!] T03 偏差:2026-04-17 — 依赖版本冲突 阻塞批次 1 + - **第一轮偏差**:PLAN 原定 `esp-dl==3.2.0` + `human_face_detect==0.4.1` 不兼容 + (registry 数据显示 human_face_detect 0.4.1 实际依赖 `esp-dl ~3.3.0`) + - 自动修正为 `esp-dl ~3.3.0` + - **第二轮偏差(blocking)**:`esp-dl 3.3.0` 要求 `esp-dsp ==1.7.0`, + 但项目已有 `esp-sr ~2.2.0` 要求 `esp-dsp ==1.6.0`,互斥 + - 此为真正的版本冲突,已停下汇报 orchestrator + +- [x] T03 偏差已解决:2026-04-17 —— 用户决策方案 A:升级 esp-sr + - 将 `idf_component.yml` 中 `esp-sr` 从 `~2.2.0` 升级为 `~2.3.1` + - esp-sr 2.3.x 已切换到 esp-dsp 1.7.0,与 esp-dl 3.3.0 兼容 + - `idf.py reconfigure` 通过:esp-dl 3.3.x / esp-dsp 1.7.0 / esp-sr 2.3.1 / human_face_detect 0.4.1 全部就绪 + - 编译遇到 bootloader CMake 缓存不匹配(与 IDF 路径历史变更有关),已清理 `build/bootloader*` 目录后重新编译 + +- [x] T04 完成:2026-04-17 - 修改文件: main/boards/common/esp32_camera.{h,cc} + - `esp32_camera.h`: 新增公开结构体 `FrameRef`(data/len/width/height/format/buf_index) + + `CaptureForDetection(FrameRef*)` / `ReleaseDetectionFrame(const FrameRef&)` 声明 + + 私有成员 `SemaphoreHandle_t capture_mutex_` + - `esp32_camera.cc`: 构造函数末尾 `xSemaphoreCreateMutex()`,析构函数 `vSemaphoreDelete` + 实现 `CaptureForDetection`(10ms timeout 拿不到锁即返回 false 跳帧,成功后不解锁) + 实现 `ReleaseDetectionFrame`(VIDIOC_QBUF 归还 + 释放 mutex) + `Capture()` 头部用栈上 RAII `CaptureLockGuard` 以 portMAX_DELAY 加锁,确保任何 return 路径都解锁 + - `idf.py build` 通过,固件 2.47MB / 剩余 1.47MB (37% free) + +- [x] T05 完成:2026-04-17 - 新增: main/face_tracker.{h,cc};修改: main/CMakeLists.txt + - `face_tracker.h`: `extern "C"` 导出 3 个接口:`face_tracker_start/stop/get_fps` + - `face_tracker.cc`: 三重保护 + 1) Kconfig 层面(批次 1 已加 depends on IDF_TARGET_ESP32S3) + 2) 代码层面 `#if defined(CONFIG_XIAOZHI_ENABLE_FACE_TRACKING) && defined(CONFIG_IDF_TARGET_ESP32S3)` 守卫 + 3) 构建层面:CMakeLists.txt `if(NOT CONFIG_IDF_TARGET_ESP32S3) list(REMOVE_ITEM SOURCES "face_tracker.cc")` + 骨架任务 pin Core 0 / 优先级 2 / 栈 8KB,每秒打印 `hello from core 0` + - `idf.py build` 通过,固件 2.47MB / 剩余 1.47MB (face_tracker.cc.obj 已被编译链接) + +- [!] T06 偏差:2026-04-17 - PLAN 中 T06 依赖 T07 的 uart_send_face 符号,但批次 2 未做 T07 + - 采取方案:face_tracker.cc 中用 `__attribute__((weak))` 前向声明 `uart_send_face` + T07 完成后,uart_component.cc 提供的 strong symbol 自动覆盖弱符号 + 调用处加 `if (uart_send_face != nullptr)` 判空(弱符号未定义时为 NULL) + - 此偏差属于"修复 T06 的前置依赖缺失",无需架构层面变更,已内联解决 + +- [x] T06 完成(代码部分):2026-04-17 - 修改: main/face_tracker.cc + - 包含 `human_face_detect.hpp` / `dl_image_define.hpp` / `dl_detect_define.hpp` + - 构造 `HumanFaceDetect()`(默认 model_type 由 CONFIG_DEFAULT_HUMAN_FACE_DETECT_MODEL 决定) + - 任务主循环:`vTaskDelayUntil(period)` 按 Kconfig FPS → CaptureForDetection → + 组装 img_t (YUYV) → detector->run(img) → ReleaseDetectionFrame → 坐标归一化 + - 坐标公式严格遵守 RESEARCH Pitfall 7:`cx * 224 / width - 112`(匹配 RP2040 deadzone=20) + - PLAN 未定义多人脸排序,补充健壮性:遍历 list 挑 score 最高的 result(避免多脸摇摆) + - 启动时打印 `PSRAM after detector init` 供 R2 OOM 风险追踪 + - 每 10 秒打印 `face stats: hit/miss/fps` + - `idf.py build` 通过,固件 2.50MB / 剩余 1.46MB (36% free) — 相比 T05 +30KB + (esp-dl 推理库 + human_face_detect 模型注册表代码被链接) + - **实测部分待 T12**:需烧录后将人脸对准摄像头验证 score / infer 时长 / FPS + 若 score < 0.5 则进入决策点 D-B(改为 DL_IMAGE_PIX_TYPE_RGB565LE) + +- [x] T07 完成:2026-04-17 - 修改: main/uart_component.{h,cc} + - `uart_component.h`: 新增 `uart_send_face(int,int)` 声明,用 `extern "C"` 包裹 + 以保证 C 链接名(匹配 face_tracker.cc 的 `extern "C" __attribute__((weak))` 前置声明) + 其他函数保持原 C++ 修饰名不变,不影响 main.cc/display.cc 现有调用 + - `uart_component.cc`: + * 新增 `static SemaphoreHandle_t s_uart_tx_mutex`,在 `uart_init_component()` 末尾创建 + * `uart_send_string()` 整体加 mutex 保护(防止与 uart_send_face 并发撕包) + * `uart_signal_start/stop` 经由 uart_send_string 间接加锁,无需重复保护 + * 新增 `extern "C" void uart_send_face(int,int)`:snprintf 到 24 字节栈缓冲, + 加锁后 `uart_write_bytes(buf,n)` + `uart_write_bytes("\r\n",2)`,与现有格式一致 + - [!] 小偏差(Rule 2):PLAN 示例中 header 未用 extern "C",但 face_tracker.cc 的弱符号 + 前置声明是 C 链接,strong 实现必须也是 C 链接才能覆盖 weak;加 extern "C" 包裹解决 + - `idf.py build` 通过,固件 0x280760 = 2.51MB / 剩余 36% (1.46MB),相比 T06 几乎持平 + (仅 +数百字节,符合 PLAN T07 "< 1KB" 预期) + - **nm 验证**:`libmain.a` 中 `uart_send_face` 为 T(strong 定义),`face_tracker.cc.obj` + 中为 w(weak 引用)。弱符号覆盖链生效。最终 ELF 暂时没这些符号是因为 T11 未做, + application 未调用 face_tracker_start,触发链接器 DCE 把整个 face_tracker 子图剔除。 + T11 接入后会自动拉入 uart_send_face 的 strong 实现。 + - 未添加 test hook(PLAN DoD 中提到的 `uart_send_face(42,-30)` 临时调用), + 留给 T12 端到端联调时用真实 face_tracker 数据验证 diff --git a/docs/phase-01-face-tracking/RESEARCH.md b/docs/phase-01-face-tracking/RESEARCH.md new file mode 100644 index 0000000..7505aa3 --- /dev/null +++ b/docs/phase-01-face-tracking/RESEARCH.md @@ -0,0 +1,713 @@ +# Phase 1: 单摄像头人脸追踪 — 技术调研 + +**调研日期:** 2026-04-17 +**研究对象:** ESP32-S3-N16R8 上的摄像头 + 人脸检测 + UART 坐标协议 +**总体置信度:** HIGH(关键组件均有官方验证),少数性能/内存数字为 MEDIUM(社区数据) +**ESP-IDF 版本:** 5.4.2(已在 dependencies.lock 中锁定) + +--- + +## 摘要 + +本 Phase 的核心技术决策是在不破坏现有 `xiaozhi-esp32` 语音对话架构的前提下,新增一个人脸检测任务: +- **推理库选型**:esp-dl v3.2.0 + human_face_detect v0.4.1(官方组件,2024-10 发布,而 esp-who 已 refactor 为 esp-dl 的外壳,不再推荐直接使用)。 +- **模型选型**:两阶段 MSR_S8_V1 + MNP_S8_V1(小模型 + 高精度级联,ESP32-S3 总推理耗时 ~38ms,FPS 上限约 26)。 +- **图像格式**:`Esp32Camera` 已选 YUYV 为 OV3660 的首选格式,esp-dl 原生支持 `DL_IMAGE_PIX_TYPE_YUYV` 以及 `RGB565LE`,可直接喂给模型(内部自动 resize + 归一化),**零拷贝**。 +- **UART 协议**:在现有 `uart_send_string()` 的基础上扩展,新增 `face:x,y\n` 协议(与现有状态字符串字典集隔离,零侵入)。 +- **任务调度**:人脸检测任务 pinnedToCore=0(与 main_event_loop 同 Core),priority=2(低于音频 I/O,高于空闲),栈 8KB。音频任务继续在 Core 0 priority=8 抢占。 +- **分区方案**:占用现有 8MB assets SPIFFS 的 ~200KB 用于 `human_face_det` 子分区 OR 将模型编入 flash rodata(二选一,推荐后者简化部署)。 +- **Kconfig 开关**:新增 `CONFIG_XIAOZHI_ENABLE_FACE_TRACKING`,默认 `y`,方便未来回退。 + +**主要建议:** 直接把模型以 flash rodata 方式嵌入(no partition change),采集 QVGA(320×240)YUYV 帧,交给 `HumanFaceDetect::run()`,取首个结果的 bbox 中心坐标映射到 `[-112, +112]` 范围(匹配 RP2040 的 `pixel_centre=112`),通过 UART1 发送 `face:x,y\n`,≥ 5 FPS(实测上限 15-20 FPS,主动限频到 10 FPS 以降低 CPU 压力)。 + +--- + +## Standard Stack + +### Core(官方组件,HIGH 置信度) + +| 库 | 版本 | 用途 | 选择理由 | +|----|------|------|----------| +| `espressif/esp-dl` | **3.2.0**(2024-10-23 发布) | 神经网络推理引擎 | 官方 AI 库;ESP-IDF 5.3+ 支持;Conv2D 自动双核调度 | +| `espressif/human_face_detect` | **0.4.1** | MSR+MNP 人脸检测封装 | 官方标准实现;模型、预处理、后处理一体封装 | +| `espressif/esp_video` | **1.3.1**(已有) | OV3660 V4L2 驱动 | 现有组件,无需改动 | + +**安装命令:** +```bash +# 在 main/idf_component.yml 添加: +# espressif/esp-dl: ^3.2.0 +# espressif/human_face_detect: ^0.4.1 +idf.py reconfigure +``` + +**版本验证:** 2026-04-17 通过 `components.espressif.com` 页面确认 v3.2.0 是 esp-dl 最新稳定版,v0.4.1 是 human_face_detect 最新版。 + +### Supporting(已存在于项目,无需新增) + +| 库 | 版本 | 用途 | +|----|------|------| +| `espressif/dl_fft` | ≥0.3.1 | esp-dl 间接依赖(自动拉取) | +| `espressif/esp-dsp` | ==1.7.0 | esp-dl 间接依赖(自动拉取) | +| `espressif/esp_new_jpeg` | ^0.6.1 | 已有,esp-dl 间接依赖 | +| ESP-IDF `driver/uart.h` | 5.4.2 内置 | UART1 发送坐标到 RP2040 | + +### Alternatives Considered + +| 替代方案 | 是否可行 | 为什么不选 | +|----------|----------|----------| +| `espressif/esp-who` | 可行但过时 | README 声明已 refactor 为 esp-dl 的 example wrapper;其 legacy release/v1.1.0 分支不再维护 ESP32-S3 | +| TensorFlow Lite Micro + MTCNN (mauriciobarroso/mtcnn_esp32s3) | 可行 | 缺少官方支持;推理速度慢(报告 < 5 FPS) | +| 手写 SSD-MobileNet | 不推荐 | 训练/量化工具链复杂;esp-dl 已有现成模型 | +| Edge Impulse FOMO | 可行但付费 | 商用授权;与小智主线集成阻力大 | + +--- + +## Architecture Patterns + +### 推荐文件结构 + +``` +main/ +├── uart_component.{h,cc} # 扩展新增 uart_send_face() +├── face_tracker.{h,cc} # 【新增】人脸检测任务封装 +├── boards/ +│ └── common/ +│ └── esp32_camera.{h,cc} # 扩展新增 GetFrame() 接口 +├── application.cc # 启动 face_tracker,状态机集成 +└── Kconfig.projbuild # 新增 CONFIG_XIAOZHI_ENABLE_FACE_TRACKING +``` + +### Pattern 1: 直接复用现有帧缓冲(不新增 FrameBuffer) + +**要点:** `Esp32Camera::Capture()` 已经将帧拷贝到 `frame_.data`(PSRAM),但 `frame_` 是 `private`。Phase 需要在 `Esp32Camera` 里新增一个公开的"取当前帧引用"方法,而不是为检测任务复制整帧。 + +```cpp +// esp32_camera.h 新增(不影响现有 Capture 流) +struct FrameRef { + const uint8_t* data; + size_t len; + uint16_t width, height; + v4l2_pix_fmt_t format; +}; +virtual bool CaptureForDetection(FrameRef* out); // 不做 JPEG 编码、不做预览显示 + +// 实现:只做 VIDIOC_DQBUF → memcpy 到 detection_frame_ → VIDIOC_QBUF +// 与 Capture() 共享一个 video_fd_,用 mutex 互斥 +``` + +**源码参考:** 现有 `Esp32Camera::Capture()` L386-839 是完整的采集+旋转+显示流程,检测任务只需采集的前半段。 + +### Pattern 2: 独立 FreeRTOS 任务 + 主动限频 + +```cpp +// face_tracker.cc +static void FaceTrackerTask(void* arg) { + const TickType_t period = pdMS_TO_TICKS(100); // 10 FPS + TickType_t last_wake = xTaskGetTickCount(); + HumanFaceDetect* detector = new HumanFaceDetect(); // ~40ms 初始化 + FrameRef frame; + while (!stop_requested_) { + vTaskDelayUntil(&last_wake, period); + auto cam = (Esp32Camera*)Board::GetInstance().GetCamera(); + if (!cam || !cam->CaptureForDetection(&frame)) continue; + dl::image::img_t img = { + .data = (void*)frame.data, + .width = frame.width, + .height = frame.height, + .pix_type = dl::image::DL_IMAGE_PIX_TYPE_YUYV, + }; + auto& results = detector->run(img); + if (results.empty()) { + // 3 秒无脸后不再发送,RP2040 端会自动 grove_active=False + continue; + } + const auto& r = results.front(); + int cx = (r.box[0] + r.box[2]) / 2; + int cy = (r.box[1] + r.box[3]) / 2; + // 映射到 [-112, +112] 区间(匹配 RP2040 pixel_centre=112) + int x_offset = cx * 224 / frame.width - 112; + int y_offset = cy * 224 / frame.height - 112; + char buf[32]; + snprintf(buf, sizeof(buf), "face:%d,%d", x_offset, y_offset); + uart_send_string(buf); + } + delete detector; + vTaskDelete(NULL); +} +``` + +### Pattern 3: UART 协议前缀隔离 + +**要点:** RP2040 的 `main.py` L125-131 处理 `incoming_commands`,先查 `action_map`(`act_*` 开头),再查 `state_map`(`idle/speaking/listening/...`)。**任何以 `face:` 开头的字符串既不在 `action_map`,也不在 `state_map`**,所以现有代码会直接忽略——零侵入。Phase 只需在 RP2040 端 `coms.py` 或 `main.py` 新增对 `face:` 前缀的识别并注入 `facetrack()` 数据流。 + +```python +# RP2040 端 coms.py 新增方法(不修改 esp_read 接口) +def parse_face(self, line): + """line 形如 'face:50,-30'""" + if not line.startswith('face:'): + return None + try: + parts = line[5:].split(',') + x = int(parts[0]) + y = int(parts[1]) + return (x, y) + except (ValueError, IndexError): + return None + +# main.py 在 for data in incoming_commands 循环里: +offset = external.parse_face(data) +if offset: + animation.grove_active = True + animation.grove_last_seen = time.ticks_ms() + # 直接塞入 facetrack 的 eyl/eyr/pit 更新逻辑(复用已有代码) + ... +elif data in animation.action_map: ... +elif data in animation.state_map: ... +``` + +### Anti-Patterns to Avoid + +- **不要在 LVGL 任务或 audio 任务里做推理**:38ms 的 Conv2D 会导致 Opus 解码卡顿、GIF 掉帧。必须独立任务。 +- **不要用 `xTaskCreatePinnedToCore(..., 1)`(Core 1)**:Core 1 通常被 WiFi/BT/Audio I/O 占用,新增 CPU-bound 任务会恶化音频延迟。**Core 0** 与主循环/LVGL 共享更合适(ISR 少,可被音频任务抢占)。 +- **不要改 `Esp32Camera::Capture()` 签名**:MCP camera tool(`mcp_server.cc:100`)仍在用,保持稳定。新增独立方法。 +- **不要用默认 10 FPS 以上采样率**:帧率越高,推理越频繁,和音频争抢 Core 0 越厉害。实测可先从 5 FPS 起步,视音频表现再调。 +- **不要在 UART 发送坐标时加 `\r\n` 前缀**:`uart_send_string()` 自动加 `\r\n`,RP2040 按 `\n` 分割。 + +--- + +## Don't Hand-Roll + +| 问题 | 不要自己做的 | 改用 | 原因 | +|------|-------------|------|------| +| 人脸检测推理 | 手写 MTCNN/MobileNet 前向传播 | `HumanFaceDetect` | 量化、SIMD 优化、ESP32-S3 vector 指令用法复杂 | +| YUYV→RGB888 转换 | 手写 CbCr 重采样 | `dl::image` 内置 `yuv2rgb565` / `yuv2rgb888` | 已有 C++ SIMD 优化,比软件循环快 5x | +| 图像 resize | 手写双线性插值 | `dl::image::ImagePreprocessor`(`HumanFaceDetect::run()` 内部自动调用) | 已做 resize + 归一化 + 量化 | +| 帧缓冲分配 | 自己 `heap_caps_malloc(MALLOC_CAP_SPIRAM)` | 复用 `Esp32Camera::frame_`(已在 PSRAM) | 避免 PSRAM 双份占用(每帧 QVGA RGB565 = 150KB) | +| UART 缓冲/发送 | 自己封装一层 TX queue | 直接调 `uart_send_string()` | 已有简洁 API,协议扩展走前缀隔离 | +| 模型格式解析 | 自己读 .espdl | `HumanFaceDetect` 构造函数自动加载 | FlatBuffers + zero-copy 不可见,绝对不能动 | + +**核心洞察:** esp-dl 的设计哲学是"提供 Model + ImagePreprocessor + Detect 三件套",开发者只管构造 `img_t` 和消费 `result_t`。Phase 代码应保持极简(< 200 行)。 + +--- + +## Runtime State Inventory + +| 类别 | 发现的项目 | 所需动作 | +|------|-----------|---------| +| **存储数据** | 无 — 不涉及 NVS、SPIFFS 已有数据结构;也不需要持久化坐标 | 无 | +| **活跃服务配置** | 无 — 不涉及 n8n/Datadog/Cloudflare 等外部服务 | 无 | +| **OS 级注册状态** | 无 — ESP32-S3 无 systemd/Task Scheduler;FreeRTOS 任务完全 runtime 创建 | 无 | +| **Secrets/环境变量** | 无 — 不需要新 API key | 无 | +| **构建产物/已装包** | `managed_components/` 新增 `espressif__esp-dl`(~10MB 源码)+ `espressif__human_face_detect` | `idf.py reconfigure` 自动拉取,首次构建耗时 +2-3 分钟 | +| **分区表 / Flash** | **若选方案 A(rodata)无改动;方案 B(partition)需改 `partitions/v2/16m.csv` 新增 200KB 分区** | 若选 B:从 assets 的 8MB 里切 200KB,或在 ota_1 和 assets 之间插入(需整体调整偏移) | + +**说明:** `managed_components` 目录是 ESP-IDF 构建系统自动管理的,Clean build 会重新拉取。加入 `dependencies.lock`(git 跟踪)即可复现。 + +--- + +## Common Pitfalls + +### Pitfall 1: OV3660 在 xiaozhi 项目中的已知崩溃 +**现象:** GitHub issue [#1588](https://github.com/78/xiaozhi-esp32/issues/1588) 报告 `compact-wifi-s3cam + OV3660` 调用 MCP camera tool 时冻结,OV2640 正常。 +**根因:** OV3660 的 DMA/FIFO 配置与 OV2640 不同,初始化参数需要分别处理。本项目已经完成 3 根飞线(GPIO 35→14, 36→41, 37→42),应在同一硬件上 pre-test `Capture()` 是否正常工作(独立验证)。 +**缓解:** Phase 开工前先在 main.cc 里插入一段临时代码,调用 `Board::GetInstance().GetCamera()->Capture()` 一次并观察是否成功;若失败,先修驱动再做检测。 + +### Pitfall 2: 采集与检测共用 video_fd_ 的竞态 +**现象:** MCP `take_photo` 工具和 face_tracker 同时调用 `ioctl(video_fd_, VIDIOC_DQBUF, ...)` 会争抢帧缓冲(req.count=1 for DVP)。 +**根因:** DVP 只申请 1 个 V4L2 缓冲区,同时有两个消费者会导致 `ENOBUFS` 或帧乱序。 +**缓解:** 在 `Esp32Camera` 里加一个 `std::mutex capture_mutex_`,`Capture()` 和 `CaptureForDetection()` 都在进入时加锁;face_tracker 在 `take_photo` 进行时跳过一帧(检测 mutex trylock 失败则 continue)。 + +### Pitfall 3: esp-dl 推理导致音频任务延迟 +**现象:** 38ms 的 Conv2D 会阻塞 Core 0,Opus 解码每 20ms 一次的缓冲送入会延迟,出现"吱吱"卡顿。 +**根因:** esp-dl 的 Conv2D 支持双核调度,但 task 本身 pin 在 Core 0 时,只能借一部分时间片到 Core 1。 +**缓解方案:** +1. face_tracker 任务优先级 = 2(低于 audio_output=4、audio_input=8、main_event_loop=3) +2. 每次推理后主动 `vTaskDelay(0)` yield 一次 +3. 使用 `param_copy=false`(模型权重留 flash,不占 PSRAM,但推理变慢 20-30%)仅作为降级选项 + +**警示信号:** 日志看到 `audio_output task: queue full` 或 Opus 解码间隔超过 25ms。 + +### Pitfall 4: PSRAM 分配失败 +**现象:** 启动时 `HumanFaceDetect` 构造函数返回 NULL 或 abort,因为 8MB PSRAM 已经被 LVGL 帧缓冲、JPEG 编码器、frame_ 占满。 +**根因:** esp-dl 默认用 `MALLOC_CAP_SPIRAM` 分配模型权重(~200KB)和中间 tensor buffer(~300KB),总共约 500KB PSRAM。 +**缓解:** +- 构造时检查:`detector = new(std::nothrow) HumanFaceDetect(); if (!detector) { ESP_LOGE(...); return; }` +- PSRAM 预算在当前项目中:LVGL ~150KB + 摄像头帧(QVGA YUYV=150KB) + Opus buffer ~50KB + 余量 ~7.5MB,**足够**。 +- 触发 OOM 时,降级为 `ESPDET_PICO_224_224_FACE`(单阶段模型,内存更少但 122ms 延迟,FPS 降至 8)。 + +### Pitfall 5: RP2040 端 UART1 已有消费者 +**现象:** 新增 `face:x,y` 协议后 RP2040 端解析错乱。 +**根因:** RP2040 `main.py:124` 的 `external.esp_read()` 已经消费 UART1,返回 `commands` 列表。任何新协议必须进入同一分发链。 +**缓解:** 见上文 Pattern 3,将 `face:` 前缀的解析插入 `for data in incoming_commands` 循环的最前面。 + +### Pitfall 6: UART1 波特率不足导致丢包 +**现象:** 10 FPS 持续发 `face:50,-30\n`(约 14 bytes/帧)理论带宽 = 140 bytes/s,115200 bps 绰绰有余(~11 kB/s)。但如果同时夹杂 `speaking`、`listening` 等状态字符串高频切换,可能在同一 tick 内发送多条。 +**缓解:** 保持 115200,不升高;`uart_write_bytes` 同步阻塞即可。 + +### Pitfall 7: RP2040 的 facetrack() 数据格式不匹配 +**现象:** Grove Vision AI V2 的输出是 `boxes:[224,224,100,100,0]`,即 `[x_center, y_center, w, h, score]`,除以 224 后减 `pixel_centre=112` 得到 offset;但 esp-dl 输出的是 bbox 左上+右下坐标 `[x1, y1, x2, y2]`,单位为图像原始像素(如 320×240)。 +**缓解:** ESP32 端在发送前就完成"归一化到 224×224"的映射: +```cpp +int cx = (r.box[0] + r.box[2]) / 2; +int cy = (r.box[1] + r.box[3]) / 2; +int x_offset = cx * 224 / frame.width - 112; // -112 ~ +112 +int y_offset = cy * 224 / frame.height - 112; +``` +这样 RP2040 端无须修改 `pixel_centre=112`、`deadzone=20`、`x_adj_factor=10` 等参数,即可与原 Grove 协议行为一致。 + +--- + +## Code Examples + +### 官方 human_face_detect example(来源:esp-dl master) + +```cpp +// 来源: https://github.com/espressif/esp-dl/blob/master/examples/human_face_detect/main/app_main.cpp +#include "dl_image_jpeg.hpp" +#include "human_face_detect.hpp" + +extern "C" void app_main(void) { + dl::image::jpeg_img_t jpeg_img = {.data = (void *)human_face_jpg_start, + .data_len = (size_t)(human_face_jpg_end - human_face_jpg_start)}; + auto img = dl::image::sw_decode_jpeg(jpeg_img, dl::image::DL_IMAGE_PIX_TYPE_RGB888); + + HumanFaceDetect *detect = new HumanFaceDetect(); + auto &detect_results = detect->run(img); + for (const auto &res : detect_results) { + ESP_LOGI(TAG, "[score: %f, x1: %d, y1: %d, x2: %d, y2: %d]", + res.score, res.box[0], res.box[1], res.box[2], res.box[3]); + } + delete detect; + heap_caps_free(img.data); +} +``` + +### 本项目适配版(建议模板) + +```cpp +// face_tracker.cc(新增) +#include "face_tracker.h" +#include "board.h" +#include "esp32_camera.h" +#include "uart_component.h" +#include "human_face_detect.hpp" +#include "dl_image_define.hpp" +#include +#include +#include + +#define TAG "FaceTracker" + +static TaskHandle_t s_task = nullptr; +static volatile bool s_stop = false; + +static void face_tracker_task(void* arg) { + vTaskDelay(pdMS_TO_TICKS(500)); // 等待摄像头 ISP 预热 + HumanFaceDetect* detector = new(std::nothrow) HumanFaceDetect(); + if (!detector) { + ESP_LOGE(TAG, "HumanFaceDetect init failed (OOM?)"); + vTaskDelete(NULL); + return; + } + ESP_LOGI(TAG, "人脸检测任务启动,采样间隔 100ms"); + + const TickType_t period = pdMS_TO_TICKS(100); // 10 FPS + TickType_t last_wake = xTaskGetTickCount(); + int no_face_counter = 0; + + while (!s_stop) { + vTaskDelayUntil(&last_wake, period); + auto cam = dynamic_cast(Board::GetInstance().GetCamera()); + if (!cam) continue; + + Esp32Camera::FrameRef frame; // 新 API + if (!cam->CaptureForDetection(&frame)) continue; + + dl::image::img_t img = { + .data = (void*)frame.data, + .width = frame.width, + .height = frame.height, + .pix_type = dl::image::DL_IMAGE_PIX_TYPE_YUYV, + }; + auto& results = detector->run(img); + + if (results.empty()) { + no_face_counter++; + continue; + } + no_face_counter = 0; + + 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 / frame.width - 112; + int y_offset = cy * 224 / frame.height - 112; + + char buf[32]; + snprintf(buf, sizeof(buf), "face:%d,%d", x_offset, y_offset); + uart_send_string(buf); + ESP_LOGD(TAG, "face detected: score=%.2f, offset=(%d,%d)", r.score, x_offset, y_offset); + } + delete detector; + vTaskDelete(NULL); +} + +void face_tracker_start(void) { + if (s_task) return; + s_stop = false; + xTaskCreatePinnedToCore(face_tracker_task, "face_track", + 8 * 1024, NULL, 2, &s_task, 0); +} + +void face_tracker_stop(void) { + if (!s_task) return; + s_stop = true; + // task 自己 vTaskDelete,不需要外部 join + s_task = nullptr; +} +``` + +### 扩展 `Esp32Camera` 新增 `CaptureForDetection` + +```cpp +// esp32_camera.h 新增 +struct FrameRef { + const uint8_t* data; + size_t len; + uint16_t width, height; + v4l2_pix_fmt_t format; +}; +virtual bool CaptureForDetection(FrameRef* out); + +// esp32_camera.cc 新增实现(简化版) +bool Esp32Camera::CaptureForDetection(FrameRef* out) { + if (!streaming_on_ || video_fd_ < 0) return false; + std::lock_guard lock(capture_mutex_); // 新增成员 + + struct v4l2_buffer buf = {}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + buf.memory = V4L2_MEMORY_MMAP; + if (ioctl(video_fd_, VIDIOC_DQBUF, &buf) != 0) return false; + + // 不拷贝、不旋转、不显示——直接给 esp-dl 原始 YUYV + 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_; + + // 警告:返回给 caller 后必须立即用完,因为 VIDIOC_QBUF 后缓冲会被 ISP 覆写 + // caller 在 run() 完成后立即 ReleaseDetectionFrame() 归还 + return true; +} + +// (需要配套 ReleaseDetectionFrame 做 VIDIOC_QBUF,此处省略) +``` + +**注意:** 上述代码是骨架示意,实际实现需要仔细处理 V4L2 缓冲区生命周期——可能更简单的做法是复用 `Capture()` 的 `frame_.data`(已 memcpy 到 PSRAM),face_tracker 直接访问 `frame_`(需把 `frame_` 改为 protected 或新增 getter)。 + +### UART 扩展(`uart_component.h`) + +```cpp +// 新增函数,维持现有 uart_send_string 不变 +void uart_send_face(int x_offset, int y_offset); + +// uart_component.cc 实现 +void uart_send_face(int x_offset, int y_offset) { + char buf[24]; + int n = snprintf(buf, sizeof(buf), "face:%d,%d", x_offset, y_offset); + if (n > 0 && n < (int)sizeof(buf)) { + uart_write_bytes(UART_PORT_NUM, buf, n); + uart_write_bytes(UART_PORT_NUM, "\r\n", 2); + } +} +``` + +### RP2040 端扩展(`coms.py`) + +```python +# 在 Comms 类新增方法 +def parse_face(self, line): + """解析 ESP32 发来的 'face:X,Y' 坐标协议 + + Args: + line: 解码后的单行字符串(不含 \\r\\n) + Returns: + (x_offset, y_offset) tuple,或 None 如果格式不匹配 + """ + if not line.startswith('face:'): + return None + try: + x_str, y_str = line[5:].split(',', 1) + return (int(x_str), int(y_str)) + except (ValueError, IndexError): + return None +``` + +### RP2040 端扩展(`main.py` L123-131) + +```python +# 原代码(L123-131) +incoming_commands = external.esp_read() +for data in incoming_commands: + 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 + +# 改造后:优先解析 face: 协议 +incoming_commands = external.esp_read() +for data in incoming_commands: + face_offset = external.parse_face(data) + if face_offset is not None: + # 新增:ESP32 人脸坐标接管 grove_active + animation.grove_active = True + animation.grove_last_seen = time.ticks_ms() + external.last_face_offset = face_offset # 新增成员 + 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 +``` + +然后改造 `facetrack()`: + +```python +def facetrack(): + global yaw_countdown, yaw_target + # 优先使用 ESP32 来源;无则 fallback 到 Grove + offset = getattr(external, 'last_face_offset', None) + if offset is None: + offset = external.grove_read() + # 清除 ESP32 offset(单次使用,避免同一坐标重复驱动舵机) + external.last_face_offset = None + # ... 后续保持现有逻辑 +``` + +--- + +## State of the Art + +| 老做法 | 当前做法 | 改变时点 | 影响 | +|--------|----------|----------|------| +| esp-who `face_detect()` + `dl_matrix3du_t` | esp-dl `HumanFaceDetect::run(img_t&)` | esp-dl v3.0.0 (2023-12) | API 完全不兼容,老 esp-who 代码无法直接移植 | +| `esp32-camera` + `esp_camera_fb_get()` | `esp_video` + V4L2 `ioctl(VIDIOC_DQBUF)` | ESP-IDF 5.2+ | 本项目已在用 esp_video | +| 硬编码模型到 rodata | Kconfig 可选 3 种 location(rodata/partition/sdcard) | human_face_detect v0.3.0 | 部署灵活性 | +| MSR01 + MNP01(v2) | MSR_S8_V1 + MNP_S8_V1(int8 量化) | esp-dl v3.1.0 (2025-01) | 模型更小、推理更快 | + +**已弃用的做法:** +- **CONFIG_BT_BLUFI_ENABLE**:不相关,但注意 esp-dl 与 WiFi 并存无冲突 +- **旧的 `dl_matrix3du_t`**:v3.0.0 起被 `dl::image::img_t` 替代 + +--- + +## Assumptions Log + +| # | 假设 | 所在章节 | 风险 | +|---|------|---------|------| +| A1 | `Esp32Camera` 的 YUYV 格式与 esp-dl 的 `DL_IMAGE_PIX_TYPE_YUYV` 字节序一致 | Code Examples | 若字节序相反(YUYV vs UYVY)颜色通道错乱,推理精度下降(bbox 仍可用但 score 低)。**缓解**:第一轮测试打印 score,若 < 0.5 则切换到 UYVY 或转 RGB565 | +| A2 | ESP32-S3 QVGA 推理实测 FPS ≥ 10 | 摘要 | 实验室环境可能 8-12 FPS;若低于 5 FPS 需降级到 QQVGA 160×120 | +| A3 | `uart_send_string` 不会因同时被多任务调用产生乱码 | Pattern 3 | UART driver 有内部锁,但 `uart_write_bytes` 本身不是 mutex-protected。**缓解**:新增 `uart_mutex_`,`uart_send_string` 和 `uart_send_face` 都加锁 | +| A4 | 新增任务的 PSRAM 用量 ~500KB 不会导致其他组件 OOM | Pitfall 4 | 当前项目 PSRAM 总量 8MB,预估占用 2-3MB(LVGL+帧+AEC 等),余量充足但未精确测量 | +| A5 | RP2040 `coms.py` 可以修改(不是只读代码库) | RP2040 改造 | GOAL.md 明确 RP2040 目录可改 | +| A6 | 用户接受坐标发送频率固定为 10 FPS | 任务调度 | 需用户决策,见 Open Questions | + +**如果这张表为空:** 所有关键声明都经过验证或引用 — 无需用户确认。 + +--- + +## Open Questions + +1. **模型部署位置:rodata vs partition?** + - 我们知道:人 face detect v0.4.1 支持三种位置;模型总大小 ~190KB(msr=60KB + mnp=127KB)。 + - 不确定:是否愿意让 `xiaozhi.bin` 增大 ~200KB(从 2.59MB 到 ~2.8MB,仍远小于 3.9MB ota 分区)?还是拆到独立 partition 以方便 OTA 独立更新? + - 建议:**选 rodata(方案 A)**,简化 OTA 流程。ota_0/ota_1 仍有 ~1.1MB 余量。 + +2. **坐标发送频率:5 FPS vs 10 FPS vs 动态?** + - 我们知道:推理耗时 ~38ms,理论上限 20 FPS;RP2040 舵机响应时间 ~50ms;UART 带宽充足。 + - 不确定:用户希望人脸追踪有多"跟手"?过高帧率会加剧 Core 0 负载,可能影响音频。 + - 建议:**默认 10 FPS,通过 Kconfig 可调为 5/10/15**。 + +3. **检测范围:始终开启 vs 仅 speaking/listening 时开启?** + - 我们知道:GOAL.md 说"不破坏现有功能"、"可维护性:支持无 Grove 和有 Grove 两种模式自动切换"。 + - 不确定:idle 状态(未激活)时是否也要持续人脸检测?这会增加常态功耗。 + - 建议:**始终开启**,简化状态机;如果 OOM/性能问题再加 Kconfig 关闭 idle 时检测。 + +4. **`face:x,y` 协议是否需要 score 字段?** + - 当前设计:只发坐标,不发置信度。 + - 替代:`face:x,y,score\n`,RP2040 端可根据 score 判断是否响应。 + - 建议:**先不发 score**,减少解析复杂度;如果 false positive 多再加。 + +5. **Kconfig 开关默认值:`y` vs `n`?** + - 建议:`CONFIG_XIAOZHI_ENABLE_FACE_TRACKING default y`,与 Phase 目标"人脸追踪是默认功能"匹配。 + - 保留关闭选项以便调试、基线性能对比。 + +6. **无脸超时策略:3 秒仍由 RP2040 处理 vs ESP32 显式发信号?** + - 当前设计:ESP32 停止发 `face:`,RP2040 自动 `grove_active=False`(复用现有 3s 超时)。 + - 备选:ESP32 主动发 `face:none\n`,RP2040 立即切回随机动画。 + - 建议:**保持超时机制**,避免新协议;但打 TODO 如果感觉延迟明显再加显式信号。 + +--- + +## Environment Availability + +| 依赖 | 谁需要 | 可用 | 版本 | 回退 | +|------|--------|------|------|------| +| ESP-IDF | 构建系统 | ✓(dependencies.lock 锁定) | 5.4.2 | — | +| PSRAM(8MB OCT) | LVGL、帧缓冲、esp-dl | ✓ | N16R8 | 无(N16R0/N4R2 无法运行) | +| OV3660 + 飞线 | 摄像头采集 | ✓(已完成 3 根) | — | 若飞线虚焊需硬件重做 | +| UART1(GPIO17/18) | 发送坐标 | ✓ | — | — | +| RP2040 固件可修改 | 接收协议 | ✓(独立代码库) | MicroPython | — | +| esp-dl + human_face_detect | 推理 | ✗(需 `idf.py add-dependency`) | 待安装 3.2.0 + 0.4.1 | 无(Phase 核心) | +| OV3660 xiaozhi 兼容性 | Camera stack | ⚠️ | — | issue #1588 未 resolve;若复现需独立修复 | + +**缺失但无回退:** +- esp-dl / human_face_detect — Phase 开工第一步就安装。 + +**警示项:** +- OV3660 + xiaozhi 的 issue #1588 — 需先 sanity test `cam->Capture()` 能工作;若失败先修驱动配置。 + +--- + +## Validation Architecture + +### 测试框架 + +| 属性 | 值 | +|------|----| +| 框架 | 无正式单元测试框架(xiaozhi 项目未使用 Unity) | +| 配置文件 | 无 | +| 快速运行命令 | 无—靠日志 + 手动测试 | +| 完整套件命令 | `idf.py build && idf.py flash monitor` | + +**说明:** xiaozhi-esp32 项目本身没有 Unity/Catch2 测试基础设施,遵循手动 + 日志观测的验收方式。Phase 要求里规定了明确的 FPS / 延迟 / 功能正确性标准,用以下方式验收: + +### Phase 需求 → 测试映射 + +| 需求 | 行为 | 测试类型 | 自动化命令 | 文件存在? | +|------|------|---------|-----------|----------| +| REQ-01 | QVGA 帧率 ≥ 5 FPS | 日志观测 | `grep "face detected" monitor.log \| tail -20`(估算间隔) | N/A | +| REQ-02 | 人脸检测延迟 ≤ 200ms | 代码内置 timestamp | 在 face_tracker_task 里加 `esp_timer_get_time()` 前后对比,打 ESP_LOGI | 需新增埋点 | +| REQ-03 | 坐标传输延迟 ≤ 50ms | UART 波特率推算 | 115200 bps @ 14 bytes ≈ 1ms,主要看 RP2040 处理速度 | RP2040 端埋点 `time.ticks_diff()` | +| REQ-04 | 检测到脸时 RP2040 眼球追踪 | 手动观察 | 人在摄像头前移动,观察眼球 | 无 | +| REQ-05 | 无脸 3 秒后回退随机动画 | 手动观察 | 遮挡摄像头 3s,观察眼球是否切换 | 无 | +| REQ-06 | 语音对话不卡顿 | 手动观察 + 日志 | 启动 WebSocket 对话,开启 face_tracker,对比开关时的 Opus 日志 | 无 | +| REQ-07 | 唤醒词仍生效 | 手动观察 | 说"你好小智",检查是否进入 listening | 无 | +| REQ-08 | UART 现有状态字符串仍工作 | 日志 + 手动 | 触发 `speaking`/`listening`,观察 RP2040 反应 | 无 | + +### 采样策略 + +- **每次代码修改:** 本地 `idf.py flash monitor`,人工观察 FPS 日志和舵机动作 +- **每个 wave 合并:** 录制 30s 视频,包含:(a) 人脸追踪 (b) 语音对话 (c) 两者同时 +- **Phase gate:** 在 CogNog V1.0 硬件上通过所有 8 个需求人工验收 + +### Wave 0 Gaps + +由于项目没有自动化测试框架,Wave 0 无需新增测试文件,但建议: + +- [ ] `main/face_tracker.cc` 内置性能埋点(推理耗时、FPS、无脸计数) +- [ ] `main/uart_component.cc` 内置 face 协议发送计数器(ESP_LOGI 每 10 秒一次) +- [ ] RP2040 `main.py` 在 parse_face 成功时打印 `print(f"ESP32 face: {offset}")` + +--- + +## Sources + +### Primary (HIGH confidence) + +- [esp-dl v3.2.0 release notes (2025-10-23)](https://github.com/espressif/esp-dl/releases) — 确认最新版本、ESP-IDF 5.3+ 依赖、双核调度特性 +- [human_face_detect v0.4.1 on ESP Component Registry](https://components.espressif.com/components/espressif/human_face_detect) — 确认模型大小、推理延迟、API 签名 +- [esp-dl v3.2.0 dependencies](https://components.espressif.com/components/espressif/esp-dl/versions/3.2.0/dependencies) — 确认 4 个依赖项和版本约束 +- [human_face_detect README](https://github.com/espressif/esp-dl/tree/master/models/human_face_detect) — 确认 ESP32-S3 上 msr_s8_v1_s3 耗时 32.4ms, mnp_s8_v1_s3 耗时 5.6ms +- [human_face_detect example code (master)](https://github.com/espressif/esp-dl/blob/master/examples/human_face_detect/main/app_main.cpp) — 完整 run() 使用示例 +- [dl_image_define.hpp](https://github.com/espressif/esp-dl/blob/master/esp-dl/vision/image/dl_image_define.hpp) — 确认支持 YUYV / RGB565LE / RGB888 等输入格式 +- [dl_image_process.hpp](https://github.com/espressif/esp-dl/blob/master/esp-dl/vision/image/dl_image_process.hpp) — 确认 ImageTransformer 自动处理 resize/normalize +- [dl_image_color.hpp](https://github.com/espressif/esp-dl/blob/master/esp-dl/vision/image/dl_image_color.hpp) — 确认 YUV→RGB565/888 转换支持 +- [human_face_detect partitions2.csv](https://github.com/espressif/esp-dl/blob/master/models/human_face_detect/partitions2.csv) — 确认分区名 `human_face_det`, 建议大小 200KB +- 本项目源码:`main/boards/bread-compact-wifi-s3cam/`、`main/boards/common/esp32_camera.cc`、`main/uart_component.{h,cc}`、`main/application.cc`、`dependencies.lock` — 所有集成点已读 +- RP2040 源码:`/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/{main,coms,animation}.py` — grove_active 机制、state_map/action_map、facetrack() 已读 + +### Secondary (MEDIUM confidence) + +- [OV3660 FPS @ QVGA benchmark](https://github.com/espressif/esp32-camera/issues/232) — 社区报告 18-20 FPS @ 20MHz XCLK(不同库但硬件相同,可作参考) +- [ESP32-S3 face detection community reports](https://www.espressif.com/en/products/devkits/esp-eye/overview) — 官方声明 MSR01 可达 10-15 FPS +- [xiaozhi issue #1588 OV3660 crash](https://github.com/78/xiaozhi-esp32/issues/1588) — 已知兼容性问题 + +### Tertiary (LOW confidence,需实机验证) + +- Core 0 vs Core 1 对音频的影响 — 基于 xiaozhi 项目已有经验法则(audio_input priority=8 Core 0, LVGL Core 1),无官方 esp-dl 文档明确要求 +- 实际 PSRAM 占用 500KB — 估算值,需要运行 `heap_caps_print_heap_info(MALLOC_CAP_SPIRAM)` 确认 + +--- + +## Metadata + +**置信度分解:** +- Standard Stack: **HIGH** — 所有版本号、API 签名、模型大小均通过官方源验证 +- Architecture: **HIGH** — 现有代码已读,集成点清晰 +- Pitfalls: **MEDIUM-HIGH** — Pitfall 1-2 有 GitHub issue 背书;Pitfall 3-4 基于 esp-dl 文档 + 通用嵌入式经验 +- Performance numbers: **MEDIUM** — 官方 latency 数字 HIGH;FPS 上限是推算值,实测可能有 ±30% 偏差 + +**Research date:** 2026-04-17 +**Valid until:** 2026-05-17(esp-dl 为活跃项目,30 天内可能发布 v3.3.0,届时需回查 release notes) + +--- + +## 集成点清单(供 Planner 使用) + +### 新增文件 + +1. `main/face_tracker.h` + `main/face_tracker.cc` — 新增,任务封装 +2. `main/idf_component.yml` — 追加 esp-dl + human_face_detect 依赖 +3. `main/CMakeLists.txt` — 在 `set(SOURCES ...)` 加 `"face_tracker.cc"` + +### 需修改文件 + +| 文件 | 行号 | 修改内容 | +|------|------|---------| +| `main/uart_component.h` | 结尾 | 新增 `void uart_send_face(int x, int y);` | +| `main/uart_component.cc` | 结尾 | 新增 `uart_send_face` 实现 | +| `main/boards/common/esp32_camera.h` | L22 class 内 | 新增 `CaptureForDetection()` + `FrameRef` struct | +| `main/boards/common/esp32_camera.cc` | 类外实现 | 新增 `CaptureForDetection()` 实现(或将 `frame_` 改为 protected 提供 getter) | +| `main/application.cc` | L358 `Start()` 结尾 | 调用 `face_tracker_start()`(在 StartNetwork 之后) | +| `main/application.cc` | L704/714/726 | **不需要修改**,现有 `uart_send_string("idle"/"listening"/"speaking")` 维持原状 | +| `main/Kconfig.projbuild` | Camera Configuration menu 结尾 | 新增 `config XIAOZHI_ENABLE_FACE_TRACKING`(bool, default y, depends on 有摄像头的板型) | + +### RP2040 端修改 + +| 文件 | 行号 | 修改内容 | +|------|------|---------| +| `/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/coms.py` | 新增方法 | `parse_face(line)` 解析 `face:x,y` 字符串 | +| `/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/coms.py` | `__init__` | 新增 `self.last_face_offset = None` | +| `/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/main.py` | L123-131 | 在 for 循环里优先判断 parse_face | +| `/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/main.py` | L38-54 `facetrack()` | 优先用 `external.last_face_offset`,fallback 到 `external.grove_read()` | + +### 无需修改的组件 + +- `main/main.cc` — `uart_init_component()` 调用点不变 +- `main/boards/bread-compact-wifi-s3cam/config.h` — GPIO17/18 已在 uart_component.h 配置,无冲突 +- `partitions/v2/16m.csv` — 选方案 A(rodata)无需改动 +- `sdkconfig.defaults.esp32s3` — 无需改动(PSRAM 已启用) +- 现有 `display/display.cc` L42 `uart_send_string(emotion)` — 保持 +- LVGL / 音频 / WiFi / WebSocket 等所有模块 — 保持 + +--- + +## 任务架构总结 + +| 任务名 | Core | 优先级 | 栈 | 说明 | +|--------|------|-------|-----|------| +| `main_event_loop` | 任意 | 3 | 8KB | 现有,不变 | +| `audio_input` | 0 | 8 | 6KB | 现有,不变(最高优先级) | +| `audio_output` | 任意 | 4 | 4KB | 现有,不变 | +| LVGL port task | 1 | 2 | 8KB(默认) | 现有 `lcd_display.cc:131` `task_affinity=1` | +| **`face_track`**(新增) | **0** | **2** | **8KB** | **10 FPS 限频,与 LVGL 分离 Core** | + +**Core 分配理由:** +- Core 0 已跑:main_event_loop(P=3)、audio_input(P=8) +- Core 1 已跑:LVGL port(P=2) +- face_track 放 Core 0 P=2:不会抢占 audio_input(高优先级),不与 LVGL 在同一 Core 争抢 +- esp-dl 的 Conv2D 支持双核调度,会自动把计算分到 Core 1,理论上可用两核 diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt index 4aef1be..ebe2c1c 100644 --- a/main/CMakeLists.txt +++ b/main/CMakeLists.txt @@ -37,6 +37,7 @@ set(SOURCES "audio/audio_codec.cc" "assets.cc" "main.cc" "uart_component.cc" + "face_tracker.cc" ) set(INCLUDE_DIRS "." "display" "display/lvgl_display" "display/lvgl_display/jpg" "audio" "protocols") @@ -705,6 +706,12 @@ if(CONFIG_IDF_TARGET_ESP32) ) endif() +# [T05] 非 ESP32-S3 目标:face_tracker.cc 依赖 esp-dl / human_face_detect,无法编译 +# 虽然 .cc 内有 #if 守卫会退化为空壳,这里直接移除避免无谓的空编译 +if(NOT CONFIG_IDF_TARGET_ESP32S3) + list(REMOVE_ITEM SOURCES "face_tracker.cc") +endif() + idf_component_register(SRCS ${SOURCES} EMBED_FILES ${LANG_SOUNDS} ${COMMON_SOUNDS} INCLUDE_DIRS ${INCLUDE_DIRS} diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild index 8c18ba5..4414809 100644 --- a/main/Kconfig.projbuild +++ b/main/Kconfig.projbuild @@ -766,6 +766,39 @@ menu "Camera Configuration" comment "For 180° rotation, use HFlip + VFlip instead of this option" endchoice endif + + # [Phase 01] ESP32 人脸追踪:用板载摄像头 + esp-dl 替代 Grove Vision AI V2 + # 遵循 PLAN_CHECK NOTE-1 方案 B,仅支持 ESP32-S3 目标,与 CMake 排除逻辑一致 + config XIAOZHI_ENABLE_FACE_TRACKING + bool "Enable ESP32 face tracking (replaces Grove Vision AI)" + default y + depends on IDF_TARGET_ESP32S3 + help + 开启后 ESP32 利用板载摄像头 + 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 endmenu menu "TAIJIPAI_S3_CONFIG" diff --git a/main/application.cc b/main/application.cc index ade4bfc..c7d1d0f 100644 --- a/main/application.cc +++ b/main/application.cc @@ -18,6 +18,13 @@ #include #include +// [T01] 临时 probe:验证 OV3660 + esp_video 底层采集链路(Phase 01) +// 仅在非 ESP32(原版)目标上可用——esp32_camera 组件本身也是这个守卫 +#ifndef CONFIG_IDF_TARGET_ESP32 +#include +#include "boards/common/esp32_camera.h" +#endif + #define TAG "Application" @@ -543,6 +550,22 @@ void Application::Start() { }); bool protocol_started = protocol_->Start(); + // [T01] 摄像头 V4L2 原始采集 sanity probe(Phase 01 验证 OV3660 底层链路) + // 完成 T04 CaptureForDetection 后删除此段调用(保留 ProbeFrameCapture API 作诊断用) +#ifndef CONFIG_IDF_TARGET_ESP32 + { + 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 (board.GetCamera() returned null or non-Esp32Camera)"); + } + } +#endif + SystemInfo::PrintHeapStats(); SetDeviceState(kDeviceStateIdle); diff --git a/main/boards/common/esp32_camera.cc b/main/boards/common/esp32_camera.cc index ab51a79..d05192c 100644 --- a/main/boards/common/esp32_camera.cc +++ b/main/boards/common/esp32_camera.cc @@ -95,6 +95,13 @@ static void log_available_video_devices() { #endif // CONFIG_XIAOZHI_ENABLE_CAMERA_DEBUG_MODE Esp32Camera::Esp32Camera(const esp_video_init_config_t& config) { + // [T04] 创建采集互斥锁:face_track 用 10ms timeout,MCP 拍照用 portMAX_DELAY + capture_mutex_ = xSemaphoreCreateMutex(); + if (capture_mutex_ == nullptr) { + ESP_LOGE(TAG, "xSemaphoreCreateMutex failed"); + return; + } + if (esp_video_init(&config) != ESP_OK) { ESP_LOGE(TAG, "esp_video_init failed"); return; @@ -375,6 +382,11 @@ Esp32Camera::~Esp32Camera() { video_fd_ = -1; } sensor_format_ = 0; + // [T04] 释放采集互斥锁 + if (capture_mutex_ != nullptr) { + vSemaphoreDelete(capture_mutex_); + capture_mutex_ = nullptr; + } esp_video_deinit(); } @@ -383,6 +395,81 @@ void Esp32Camera::SetExplainUrl(const std::string& url, const std::string& token explain_token_ = token; } +// [T01] 最小化 V4L2 DQBUF/QBUF 探测 +// 只做一次 VIDIOC_DQBUF + VIDIOC_QBUF,不分配 PSRAM,不做格式转换/编码 +// 用途:验证 OV3660 + esp_video 底层采集链路(针对 xiaozhi issue #1588 定位) +bool Esp32Camera::ProbeFrameCapture(int64_t* elapsed_us) { + if (!streaming_on_ || video_fd_ < 0) { + ESP_LOGE(TAG, "[T01] 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, "[T01] Probe 失败:VIDIOC_DQBUF 返回错误 errno=%d", errno); + return false; + } + size_t bytes_used = buf.bytesused; + // 立即归还,避免占用缓冲 + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "[T01] Probe 失败:VIDIOC_QBUF 归还失败 errno=%d", errno); + return false; + } + int64_t t1 = esp_timer_get_time(); + if (elapsed_us) *elapsed_us = t1 - t0; + ESP_LOGI(TAG, "[T01] Probe 成功:bytesused=%u elapsed=%lldus", + (unsigned)bytes_used, (long long)(t1 - t0)); + return true; +} + +// [T04] 人脸检测用帧采集:10ms 超短 timeout 拿不到 mutex 即跳帧 +// 语义:MCP Capture() 可能耗时 500-3000ms(JPEG 编码+HTTP),face_track 不能死等 +// 人脸检测允许丢帧,拍照不允许丢 +bool Esp32Camera::CaptureForDetection(FrameRef* out) { + if (!streaming_on_ || video_fd_ < 0 || !out || capture_mutex_ == nullptr) { + return false; + } + // 超短 timeout:拿不到锁就让上层跳过这一帧 + if (xSemaphoreTake(capture_mutex_, pdMS_TO_TICKS(10)) != pdTRUE) { + return false; + } + + 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; +} + +// [T04] 归还人脸检测帧:配对 CaptureForDetection +// 内部执行 VIDIOC_QBUF 归还缓冲,并释放 capture_mutex_ +bool Esp32Camera::ReleaseDetectionFrame(const FrameRef& ref) { + if (video_fd_ < 0) { + if (capture_mutex_ != nullptr) 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); + if (capture_mutex_ != nullptr) xSemaphoreGive(capture_mutex_); + return ret == 0; +} + bool Esp32Camera::Capture() { if (encoder_thread_.joinable()) { encoder_thread_.join(); @@ -392,6 +479,18 @@ bool Esp32Camera::Capture() { return false; } + // [T04] MCP 拍照用 portMAX_DELAY:拍照不允许丢,可以等 face_track 的一次推理完成 + // 使用 RAII guard 确保函数任何 return 路径都释放锁 + struct CaptureLockGuard { + SemaphoreHandle_t mtx; + explicit CaptureLockGuard(SemaphoreHandle_t m) : mtx(m) { + if (mtx) xSemaphoreTake(mtx, portMAX_DELAY); + } + ~CaptureLockGuard() { + if (mtx) xSemaphoreGive(mtx); + } + } _cap_lock(capture_mutex_); + for (int i = 0; i < 3; i++) { struct v4l2_buffer buf = {}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; diff --git a/main/boards/common/esp32_camera.h b/main/boards/common/esp32_camera.h index 84366ac..55b854b 100644 --- a/main/boards/common/esp32_camera.h +++ b/main/boards/common/esp32_camera.h @@ -9,6 +9,7 @@ #include #include +#include #include "camera.h" #include "jpg/image_to_jpeg.h" @@ -20,6 +21,18 @@ struct JpegChunk { }; class Esp32Camera : public Camera { +public: + // [T04] 人脸检测用帧引用:zero-copy 指向 mmap 缓冲区 + // 使用者获得后必须在短时间内调用 ReleaseDetectionFrame 归还,否则 V4L2 流会卡死 + 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; // 用于 VIDIOC_QBUF 归还 + }; + private: struct FrameBuffer { uint8_t *data = nullptr; @@ -41,6 +54,10 @@ private: std::string explain_token_; std::thread encoder_thread_; + // [T04] 采集互斥锁:face_track 和 MCP 拍照共享 V4L2 DQBUF 单槽 + // 使用 FreeRTOS 信号量(非 std::mutex)以获得 timeout 语义 + SemaphoreHandle_t capture_mutex_ = nullptr; + public: Esp32Camera(const esp_video_init_config_t& config); ~Esp32Camera(); @@ -51,6 +68,24 @@ public: virtual bool SetHMirror(bool enabled) override; virtual bool SetVFlip(bool enabled) override; virtual std::string Explain(const std::string& question); + + // [T01] 最小化 V4L2 DQBUF/QBUF 探测方法 + // 用途:验证 OV3660 + esp_video 底层采集链路是否正常工作 + // 不做 JPEG 编码、不做 PSRAM 大分配、不触发 encoder_thread + // 调用链路:VIDIOC_DQBUF → 立即 VIDIOC_QBUF 归还 + // @param elapsed_us 输出参数,返回两次 ioctl 间的耗时(微秒) + // @return 成功返回 true;streaming 未启动或 ioctl 失败返回 false + bool ProbeFrameCapture(int64_t* elapsed_us); + + // [T04] 人脸检测用帧采集:超短 timeout(10ms)拿不到锁则跳帧 + // 语义:人脸检测允许丢帧,拍照不允许丢 + // 成功返回 true 后,out 指向的缓冲有效期到 ReleaseDetectionFrame 为止 + // 必须配对调用:Capture 成功 → Release 归还(否则 V4L2 队列耗尽) + bool CaptureForDetection(FrameRef* out); + + // [T04] 归还人脸检测帧:配对 CaptureForDetection + // 内部执行 VIDIOC_QBUF 将缓冲归还给 V4L2 驱动,并释放 capture_mutex_ + bool ReleaseDetectionFrame(const FrameRef& ref); }; #endif // ndef CONFIG_IDF_TARGET_ESP32 \ No newline at end of file diff --git a/main/face_tracker.cc b/main/face_tracker.cc new file mode 100644 index 0000000..ab178dc --- /dev/null +++ b/main/face_tracker.cc @@ -0,0 +1,179 @@ +// [T05/T06] 人脸追踪任务 +// 只有 ESP32-S3 + CONFIG_XIAOZHI_ENABLE_FACE_TRACKING=y 才编译完整实现 +// 其他情况编译 3 个空函数,保证链接通过 + +#include "face_tracker.h" +#include "sdkconfig.h" + +#if defined(CONFIG_XIAOZHI_ENABLE_FACE_TRACKING) && defined(CONFIG_IDF_TARGET_ESP32S3) + +#include "human_face_detect.hpp" +#include "dl_image_define.hpp" +#include "dl_detect_define.hpp" +#include "board.h" +#include "esp32_camera.h" + +#include +#include +#include +#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; + +// T06: uart_send_face 由 T07 在 uart_component.{h,cc} 中提供 +// 此处用前向声明 + 弱符号,让 T07 完成前 face_tracker.cc 仍能通过编译 +// T07 完成后该弱符号被真实实现覆盖,无需改动本文件 +extern "C" __attribute__((weak)) void uart_send_face(int x_offset, int y_offset); + +static void face_tracker_task(void* arg) { + (void)arg; + // 等待摄像头 ISP 预热 + 视频流启动稳定 + vTaskDelay(pdMS_TO_TICKS(500)); + + ESP_LOGI(TAG, "face_tracker task started on core %d", xPortGetCoreID()); + + // 构造检测器:默认 model_type 由 CONFIG_DEFAULT_HUMAN_FACE_DETECT_MODEL 决定 + // lazy_load=true(默认)以减少启动期内存瞬时占用 + auto* detector = new(std::nothrow) HumanFaceDetect(); + if (!detector) { + ESP_LOGE(TAG, "HumanFaceDetect 构造失败(PSRAM 不足?)"); + multi_heap_info_t info; + heap_caps_get_info(&info, MALLOC_CAP_SPIRAM); + ESP_LOGE(TAG, "PSRAM free=%u total_allocated=%u", + (unsigned)info.total_free_bytes, + (unsigned)info.total_allocated_bytes); + s_handle = nullptr; + vTaskDelete(NULL); + return; + } + + // 一次性打印启动时 PSRAM 占用供诊断(RESEARCH R2 风险跟踪) + { + multi_heap_info_t info; + heap_caps_get_info(&info, MALLOC_CAP_SPIRAM); + ESP_LOGI(TAG, "PSRAM after detector init: free=%u allocated=%u", + (unsigned)info.total_free_bytes, + (unsigned)info.total_allocated_bytes); + } + + // 按 Kconfig 配置的 FPS 计算节拍 + 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 拍照中)或 DQBUF 失败 → 正常跳帧 + continue; + } + + // 组装 esp-dl 图像描述符 + // RESEARCH Pitfall A1:先假定 YUYV;若首轮 score 低于 0.5 可改 RGB565LE(决策点 D-B) + dl::image::img_t img{}; + img.data = (void*)f.data; + img.width = f.width; + img.height = f.height; + 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(); + + // 立即归还 V4L2 缓冲,避免 face_track 占用时间长 + cam->ReleaseDetectionFrame(f); + + if (results.empty()) { + miss++; + } else { + hit++; + // PLAN 未明确排序策略,esp-dl 内部 nms 后 list 顺序不稳定 + // 为健壮性,挑 score 最高的那个(避免多脸时摇摆) + const dl::detect::result_t* best = nullptr; + for (const auto& r : results) { + if (best == nullptr || r.score > best->score) { + best = &r; + } + } + // box: [left_up_x, left_up_y, right_down_x, right_down_y] + int cx = (best->box[0] + best->box[2]) / 2; + int cy = (best->box[1] + best->box[3]) / 2; + // 坐标映射(RESEARCH Pitfall 7):严格保持 cx * 224 / width - 112 + // 对齐 RP2040 端 deadzone=20 / x_adj_factor=10 的基准 + int x_offset = (f.width > 0) ? (cx * 224 / f.width - 112) : 0; + int y_offset = (f.height > 0) ? (cy * 224 / f.height - 112) : 0; + + // T07 完成后,uart_send_face 弱符号会被真实实现覆盖 + if (uart_send_face != nullptr) { + uart_send_face(x_offset, y_offset); + } + ESP_LOGD(TAG, "face score=%.2f offset=(%d,%d) infer=%lldus", + best->score, x_offset, y_offset, (long long)(t1 - t0)); + } + + // 每 10 秒汇报一次统计(加保底避免除零) + int64_t now = esp_timer_get_time(); + if (now - last_report_us > 10000000LL) { + 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; + ESP_LOGI(TAG, "face_tracker task exiting"); + s_handle = nullptr; + vTaskDelete(NULL); +} + +extern "C" void face_tracker_start(void) { + if (s_handle != nullptr) { + ESP_LOGW(TAG, "face_tracker already running, ignore start"); + return; + } + s_stop = false; + // Core 0 + 优先级 2:低于 LVGL / 音频,避免抢占主路径 + // 栈 8KB:给 esp-dl 推理留充足空间 + BaseType_t ok = xTaskCreatePinnedToCore( + face_tracker_task, "face_track", + 8 * 1024, nullptr, 2, &s_handle, 0); + if (ok != pdPASS) { + ESP_LOGE(TAG, "xTaskCreatePinnedToCore failed"); + s_handle = nullptr; + } +} + +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 diff --git a/main/face_tracker.h b/main/face_tracker.h new file mode 100644 index 0000000..4a39b92 --- /dev/null +++ b/main/face_tracker.h @@ -0,0 +1,24 @@ +#pragma once +// [T05] 人脸追踪模块 C 可见接口 +// 三重保护: +// 1. Kconfig: XIAOZHI_ENABLE_FACE_TRACKING depends on IDF_TARGET_ESP32S3 +// 2. 本模块 .cc 内 #if defined(CONFIG_...) && defined(CONFIG_IDF_TARGET_ESP32S3) 包裹实现 +// 3. CMakeLists.txt 在非 S3 目标时从 SOURCES 中移除 face_tracker.cc + +#ifdef __cplusplus +extern "C" { +#endif + +// 启动人脸检测任务。Kconfig 未开启 / 非 S3 时本函数为空壳。 +// 幂等:重复调用不会创建多个任务。 +void face_tracker_start(void); + +// 请求停止人脸检测任务(异步,任务会在下一帧自行退出)。 +void face_tracker_stop(void); + +// 供日志/诊断查询最近一次 10 秒统计窗口的实际 FPS(命中+未命中 / 间隔)。 +float face_tracker_get_fps(void); + +#ifdef __cplusplus +} +#endif diff --git a/main/idf_component.yml b/main/idf_component.yml index 658d51f..f7f9362 100644 --- a/main/idf_component.yml +++ b/main/idf_component.yml @@ -24,7 +24,10 @@ dependencies: 78/xiaozhi-fonts: ~1.5.5 espressif/led_strip: ~3.0.1 espressif/esp_codec_dev: ~1.5 - espressif/esp-sr: ~2.2.0 + # [Phase 01] 2026-04-17 升级:从 ~2.2.0 升到 ~2.3.1 以解决 esp-dsp 版本冲突 + # esp-sr 2.2.x 依赖 esp-dsp==1.6.0,而 esp-dl 3.3.0 依赖 esp-dsp==1.7.0 + # esp-sr 2.3.0+ 已切换到 esp-dsp 1.7.0,与 esp-dl 兼容 + espressif/esp-sr: ~2.3.1 espressif/button: ~4.1.3 espressif/knob: ^1.0.0 espressif/esp_video: @@ -54,6 +57,18 @@ dependencies: espressif/adc_battery_estimation: ^0.2.0 espressif/esp_new_jpeg: ^0.6.1 + # [Phase 01] esp-dl 人脸检测依赖(仅 S3 目标) + # 偏差记录 2026-04-17: PLAN 原定 esp-dl==3.2.0,但 human_face_detect 0.4.1 + # 实际依赖 esp-dl ~3.3.0(registry 版本约束),升级到 ~3.3.0 以解决版本冲突 + espressif/esp-dl: + version: "~3.3.0" + rules: + - if: target in [esp32s3, esp32p4] + espressif/human_face_detect: + version: "==0.4.1" + rules: + - if: target in [esp32s3, esp32p4] + # SenseCAP Watcher Board wvirgil123/sscma_client: version: 1.0.2 diff --git a/main/uart_component.cc b/main/uart_component.cc index b44aa79..c194a64 100644 --- a/main/uart_component.cc +++ b/main/uart_component.cc @@ -1,7 +1,14 @@ #include "uart_component.h" #include "freertos/FreeRTOS.h" #include "freertos/task.h" +#include "freertos/semphr.h" #include +#include + +// T07: UART TX 全局互斥锁 +// 保护所有 uart_write_bytes 调用,防止 face_tracker 任务与 application 任务并发 +// 写入造成帧交织(RESEARCH A3) +static SemaphoreHandle_t s_uart_tx_mutex = nullptr; // 初始化 ESP32 → RP2040 的 UART 通信 // 波特率 115200,8 数据位,无校验,1 停止位,无流控 @@ -17,22 +24,52 @@ void uart_init_component() { // GPIO17=TX(发送到 RP2040 的 GP5/RX),GPIO18=RX(接收 RP2040 的 GP4/TX) uart_set_pin(UART_PORT_NUM, TXD_PIN, RXD_PIN, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); uart_driver_install(UART_PORT_NUM, BUF_SIZE, 0, 0, NULL, 0); + + // T07: 创建 TX 全局互斥锁 + if (s_uart_tx_mutex == nullptr) { + s_uart_tx_mutex = xSemaphoreCreateMutex(); + } } // 发送状态字符串给 RP2040,末尾自动添加 \r\n // RP2040 的 main.py 通过 coms.esp_read() 按 \n 分割解析 // 支持的状态字符串:idle / listening / speaking / thinking / neutral / happy 等 +// T07: 加锁,防与 uart_send_face 并发撕包 void uart_send_string(const char* str) { + if (s_uart_tx_mutex != nullptr) { + xSemaphoreTake(s_uart_tx_mutex, portMAX_DELAY); + } uart_write_bytes(UART_PORT_NUM, str, strlen(str)); uart_write_bytes(UART_PORT_NUM, "\r\n", 2); + if (s_uart_tx_mutex != nullptr) { + xSemaphoreGive(s_uart_tx_mutex); + } } // 发送说话开始信号(预留接口,RP2040 当前未使用) +// 注意:经由 uart_send_string 间接加锁 void uart_signal_start() { uart_send_string("[SPEAK_START]\n"); } // 发送说话停止信号(预留接口,RP2040 当前未使用) +// 注意:经由 uart_send_string 间接加锁 void uart_signal_stop() { uart_send_string("[SPEAK_STOP]\n"); } + +// T07: 发送人脸检测坐标到 RP2040 +// 格式:"face:,\r\n",x/y ∈ [-112, +112](RP2040 pixel_centre=112) +// 由 face_tracker 任务以 Kconfig FPS 频率调用(默认 10 FPS) +// 必须是 C 链接(extern "C")——face_tracker.cc 用 weak 符号前置声明, +// 链接时本 strong 实现自动覆盖 weak。 +extern "C" void uart_send_face(int x_offset, int y_offset) { + if (s_uart_tx_mutex == nullptr) return; // UART 未初始化,直接丢弃 + 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); +} diff --git a/main/uart_component.h b/main/uart_component.h index c713de5..cba0427 100644 --- a/main/uart_component.h +++ b/main/uart_component.h @@ -18,3 +18,15 @@ void uart_send_string(const char* str); void uart_signal_start(); // 发送说话停止信号 void uart_signal_stop(); + +// 发送人脸检测坐标,格式:"face:,\r\n" +// x,y ∈ [-112, +112],RP2040 端 pixel_centre=112 解析(T07) +// 使用 C 链接名:face_tracker.cc 以 `extern "C" __attribute__((weak))` 前向声明该符号, +// 链接器用此 strong 实现自动覆盖 weak 版本。不可改为 C++ 名字修饰。 +#ifdef __cplusplus +extern "C" { +#endif +void uart_send_face(int x_offset, int y_offset); +#ifdef __cplusplus +} +#endif