Phase 01 批次 1-3: 单摄像头人脸追踪基础设施

实现 ESP32-S3 上单摄像头人脸追踪的核心代码骨架,替代 Grove Vision AI V2
模块,通过 UART 发送人脸坐标驱动 RP2040 控制的眼球/YAW 舵机。

## 规划文档(docs/phase-01-face-tracking/)

- GOAL.md       Phase 目标与 5 大成功标准
- RESEARCH.md   esp-dl v3.2/3.3 + human_face_detect 0.4.1 技术调研
- PLAN.md       15 个原子任务的执行计划(T01-T15)
- PLAN_CHECK.md 计划审查报告(PASS_WITH_NOTES)
- PROGRESS.md   执行进度追踪(批次 1-3 已完成)

## 批次 1:依赖与开关(T01-T03)

- main/idf_component.yml
  新增 esp-dl ~3.3.0 + human_face_detect 0.4.1(仅 S3/P4)
  esp-sr 从 ~2.2.0 升级到 ~2.3.1,解决 esp-dsp 1.6/1.7 版本冲突
- main/Kconfig.projbuild
  新增 CONFIG_XIAOZHI_ENABLE_FACE_TRACKING 开关(默认 y,depends on S3)
  新增 CONFIG_XIAOZHI_FACE_TRACKING_FPS_CHOICE(5/10/15)
- main/boards/common/esp32_camera.{h,cc}
  新增 ProbeFrameCapture() 最小 V4L2 DQBUF/QBUF 探针(T01)
- main/application.cc
  Start() 末尾调用 probe 验证摄像头硬件链路

## 批次 2:人脸检测核心(T04-T06)

- main/boards/common/esp32_camera.{h,cc}
  新增 FrameRef 结构体 + CaptureForDetection/ReleaseDetectionFrame
  双超时 mutex 策略:face_tracker 10ms timeout 跳帧,Capture() RAII guard
- main/face_tracker.{h,cc}(新建)
  Core 0 / 优先级 2 / 栈 8KB 独立任务
  集成 esp-dl HumanFaceDetect 推理
  坐标归一化 cx*224/W-112,匹配 RP2040 pixel_centre=112
  多人脸遍历挑 score 最高,避免多脸时眼球摇摆
  三重保护:Kconfig depends on S3 + 源文件 #if 守卫 + CMake 条件排除
- main/CMakeLists.txt
  非 S3 目标从 SOURCES 移除 face_tracker.cc

## 批次 3:UART 协议扩展(T07)

- main/uart_component.{h,cc}
  新增 uart_send_face(x,y) 发送 face:x,y\r\n 协议
  extern "C" 链接名配合 face_tracker 的弱符号声明
  全局 TX mutex 保护所有 UART 写入,防并发帧交织
  uart_send_string 同步加锁保持一致性

## 编译验证

idf.py build 通过,固件 2.51MB / 剩余 1.46MB (36% free)
当前 face_tracker 未被 application 激活(留到 T11),
UART/摄像头现有功能零影响。

## 未完成(下次继续)

- T01 硬件 probe 实机验证
- T08-T10 RP2040 端 parse_face + facetrack 双数据源改造
- T11-T15 application 接入 + 端到端联调 + 性能调优 + 最终验收

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Rdzleo 2026-04-17 18:24:27 +08:00
parent e61d8f2175
commit e95d0c414e
15 changed files with 2768 additions and 1 deletions

View File

@ -0,0 +1,92 @@
# Phase 1: 单摄像头人脸追踪
## 目标
将 ESP32-S3 上的 OV3660 摄像头用作人脸追踪数据源,替代 Grove Vision AI V2 模块,驱动 RP2040 控制的眼球EYL/EYR和身体YAW舵机追踪人脸移动。
## 硬件环境
- **ESP32 模组**: ESP32-S3-WROOM-1-N16R816MB 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 FPSQVGA 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追踪流畅度可能下降

File diff suppressed because it is too large Load Diff

View File

@ -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 删除重复更新代码 + T09L659注释明确"只在此处更新" |
| 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 #1T10 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-702idle 判断放在数据消费 + 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 追踪)、测试 2idle 不追踪)、测试 33s 超时)、测试 4staticflag 联动)四项覆盖。
### BLOCKER #2grove_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。
- **职责划分清晰**
- T09incoming_commands 收到 face: → set True + 更新 last_seen
- T10facetrack 检查超时 → set False
- 两处不冲突,未来 `grove_last_seen` 真正反映"最后一次收到 ESP32 数据的时间"。
### BLOCKER #3T01 最小 probe
- **改为 V4L2 直接 ioctl**L75-99只调一次 `VIDIOC_DQBUF` + `VIDIOC_QBUF`,不触发 JPEG 编码、不做 PSRAM 大分配、不触发 encoder_thread。
- **执行时间预算合理**DoDL116要求 `elapsed < 200ms`——这是合理的V4L2 DQBUF 在 10 FPS 下理论 < 100ms 就能拿到下一帧DVP 唤醒延迟也在此预算内)。
- **probe 调用位置正确**L101-112放在 `Application::Start()` 末尾、`protocol_->Start()` 之后——此时 `Esp32Camera` 已构造完成,`streaming_on_=true`video_fd_ 已 open。
- **保留诊断 API**L114T04 完成后只删 probe 调用、保留 `ProbeFrameCapture` API 作为诊断工具——比原方案"完全删除"更稳。
### HIGH #1CMakeLists 非 S3
- **三重保护明确写入** T05L370-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 includeT05 L319
- **兼容性表**L388-394覆盖 ESP32-S3 / P4 / 原版 / C3 / C6 五种目标的预期行为。
- **DoD 加入交叉验证**L397"ESP32原版编译通过验证非 S3 目标不会因 human_face_detect.hpp 缺失而失败)"。
- **轻微注意**T05 L392 写"ESP32-P4face_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 #2Capture 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-799listening 状态下触发 MCP take_photo观察 face_tracker 跳帧而非卡死。
- **轻微注意**`CaptureForDetection``ReleaseDetectionFrame` 的 mutex 是**跨调用持有**——`CaptureForDetection` 拿锁后不解锁,由后续的 `ReleaseDetectionFrame` 解锁。这是正确的(保护 mmap_buffers_[buf.index] 的内容直到 caller 用完),但 face_tracker_task 必须保证两者**严格成对**调用。**详见第 4 节 NOTE-2**。
### HIGH #3staticflag 漂移)
- **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 依赖 T07v1.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 L139Kconfig `depends on IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4` vs T05 L380CMake `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-N16R8P4 路径根本不会被走到。但作为面向其他用户的 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 涉及修改的任务"明确列举L18T01、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]` 标记T01L63、T04L206、T05L287、T08L549、T10L667—— **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-1P4 行为不一致)**:执行 T02/T05 时如果方便,顺手统一 Kconfig 和 CMake 的目标列表(建议改 Kconfig 为 `depends on IDF_TARGET_ESP32S3` 只支持 S3因为本项目硬件就是 S3
2. **NOTE-2CaptureForDetection RAII**:执行 T04/T06 时考虑加 RAII guard 或至少加警示注释,避免未来维护引入 mutex 永久持有的 bug。
3. **R14FACE_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**,进入执行阶段。

View File

@ -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 choice5/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 2PLAN 示例中 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` 为 Tstrong 定义),`face_tracker.cc.obj`
中为 wweak 引用)。弱符号覆盖链生效。最终 ELF 暂时没这些符号是因为 T11 未做,
application 未调用 face_tracker_start触发链接器 DCE 把整个 face_tracker 子图剔除。
T11 接入后会自动拉入 uart_send_face 的 strong 实现。
- 未添加 test hookPLAN DoD 中提到的 `uart_send_face(42,-30)` 临时调用),
留给 T12 端到端联调时用真实 face_tracker 数据验证

View File

@ -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 总推理耗时 ~38msFPS 上限约 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 同 Corepriority=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采集 QVGA320×240YUYV 帧,交给 `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 SchedulerFreeRTOS 任务完全 runtime 创建 | 无 |
| **Secrets/环境变量** | 无 — 不需要新 API key | 无 |
| **构建产物/已装包** | `managed_components/` 新增 `espressif__esp-dl`~10MB 源码)+ `espressif__human_face_detect` | `idf.py reconfigure` 自动拉取,首次构建耗时 +2-3 分钟 |
| **分区表 / Flash** | **若选方案 Arodata无改动方案 Bpartition需改 `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 0Opus 解码每 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/s115200 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 <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#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<Esp32Camera*>(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<std::mutex> 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 到 PSRAMface_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 种 locationrodata/partition/sdcard | human_face_detect v0.3.0 | 部署灵活性 |
| MSR01 + MNP01v2 | MSR_S8_V1 + MNP_S8_V1int8 量化) | 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-3MBLVGL+帧+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 支持三种位置;模型总大小 ~190KBmsr=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 FPSRP2040 舵机响应时间 ~50msUART 带宽充足。
- 不确定:用户希望人脸追踪有多"跟手"?过高帧率会加剧 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 | — |
| PSRAM8MB OCT | LVGL、帧缓冲、esp-dl | ✓ | N16R8 | 无N16R0/N4R2 无法运行) |
| OV3660 + 飞线 | 摄像头采集 | ✓(已完成 3 根) | — | 若飞线虚焊需硬件重做 |
| UART1GPIO17/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 数字 HIGHFPS 上限是推算值,实测可能有 ±30% 偏差
**Research date:** 2026-04-17
**Valid until:** 2026-05-17esp-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` — 选方案 Arodata无需改动
- `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_loopP=3、audio_inputP=8
- Core 1 已跑LVGL portP=2
- face_track 放 Core 0 P=2不会抢占 audio_input高优先级不与 LVGL 在同一 Core 争抢
- esp-dl 的 Conv2D 支持双核调度,会自动把计算分到 Core 1理论上可用两核

View File

@ -37,6 +37,7 @@ set(SOURCES "audio/audio_codec.cc"
"assets.cc" "assets.cc"
"main.cc" "main.cc"
"uart_component.cc" "uart_component.cc"
"face_tracker.cc"
) )
set(INCLUDE_DIRS "." "display" "display/lvgl_display" "display/lvgl_display/jpg" "audio" "protocols") set(INCLUDE_DIRS "." "display" "display/lvgl_display" "display/lvgl_display/jpg" "audio" "protocols")
@ -705,6 +706,12 @@ if(CONFIG_IDF_TARGET_ESP32)
) )
endif() 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} idf_component_register(SRCS ${SOURCES}
EMBED_FILES ${LANG_SOUNDS} ${COMMON_SOUNDS} EMBED_FILES ${LANG_SOUNDS} ${COMMON_SOUNDS}
INCLUDE_DIRS ${INCLUDE_DIRS} INCLUDE_DIRS ${INCLUDE_DIRS}

View File

@ -766,6 +766,39 @@ menu "Camera Configuration"
comment "For 180° rotation, use HFlip + VFlip instead of this option" comment "For 180° rotation, use HFlip + VFlip instead of this option"
endchoice endchoice
endif 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 endmenu
menu "TAIJIPAI_S3_CONFIG" menu "TAIJIPAI_S3_CONFIG"

View File

@ -18,6 +18,13 @@
#include <font_awesome.h> #include <font_awesome.h>
#include <uart_component.h> #include <uart_component.h>
// [T01] 临时 probe验证 OV3660 + esp_video 底层采集链路Phase 01
// 仅在非 ESP32原版目标上可用——esp32_camera 组件本身也是这个守卫
#ifndef CONFIG_IDF_TARGET_ESP32
#include <esp_timer.h>
#include "boards/common/esp32_camera.h"
#endif
#define TAG "Application" #define TAG "Application"
@ -543,6 +550,22 @@ void Application::Start() {
}); });
bool protocol_started = protocol_->Start(); bool protocol_started = protocol_->Start();
// [T01] 摄像头 V4L2 原始采集 sanity probePhase 01 验证 OV3660 底层链路)
// 完成 T04 CaptureForDetection 后删除此段调用(保留 ProbeFrameCapture API 作诊断用)
#ifndef CONFIG_IDF_TARGET_ESP32
{
auto* cam = dynamic_cast<Esp32Camera*>(board.GetCamera());
if (cam) {
int64_t elapsed = 0;
bool ok = cam->ProbeFrameCapture(&elapsed);
ESP_LOGI("T01_Probe", "V4L2 probe result=%d elapsed=%lldus",
ok, (long long)elapsed);
} else {
ESP_LOGW("T01_Probe", "no camera instance (board.GetCamera() returned null or non-Esp32Camera)");
}
}
#endif
SystemInfo::PrintHeapStats(); SystemInfo::PrintHeapStats();
SetDeviceState(kDeviceStateIdle); SetDeviceState(kDeviceStateIdle);

View File

@ -95,6 +95,13 @@ static void log_available_video_devices() {
#endif // CONFIG_XIAOZHI_ENABLE_CAMERA_DEBUG_MODE #endif // CONFIG_XIAOZHI_ENABLE_CAMERA_DEBUG_MODE
Esp32Camera::Esp32Camera(const esp_video_init_config_t& config) { Esp32Camera::Esp32Camera(const esp_video_init_config_t& config) {
// [T04] 创建采集互斥锁face_track 用 10ms timeoutMCP 拍照用 portMAX_DELAY
capture_mutex_ = xSemaphoreCreateMutex();
if (capture_mutex_ == nullptr) {
ESP_LOGE(TAG, "xSemaphoreCreateMutex failed");
return;
}
if (esp_video_init(&config) != ESP_OK) { if (esp_video_init(&config) != ESP_OK) {
ESP_LOGE(TAG, "esp_video_init failed"); ESP_LOGE(TAG, "esp_video_init failed");
return; return;
@ -375,6 +382,11 @@ Esp32Camera::~Esp32Camera() {
video_fd_ = -1; video_fd_ = -1;
} }
sensor_format_ = 0; sensor_format_ = 0;
// [T04] 释放采集互斥锁
if (capture_mutex_ != nullptr) {
vSemaphoreDelete(capture_mutex_);
capture_mutex_ = nullptr;
}
esp_video_deinit(); esp_video_deinit();
} }
@ -383,6 +395,81 @@ void Esp32Camera::SetExplainUrl(const std::string& url, const std::string& token
explain_token_ = 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-3000msJPEG 编码+HTTPface_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() { bool Esp32Camera::Capture() {
if (encoder_thread_.joinable()) { if (encoder_thread_.joinable()) {
encoder_thread_.join(); encoder_thread_.join();
@ -392,6 +479,18 @@ bool Esp32Camera::Capture() {
return false; 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++) { for (int i = 0; i < 3; i++) {
struct v4l2_buffer buf = {}; struct v4l2_buffer buf = {};
buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;

View File

@ -9,6 +9,7 @@
#include <freertos/FreeRTOS.h> #include <freertos/FreeRTOS.h>
#include <freertos/queue.h> #include <freertos/queue.h>
#include <freertos/semphr.h>
#include "camera.h" #include "camera.h"
#include "jpg/image_to_jpeg.h" #include "jpg/image_to_jpeg.h"
@ -20,6 +21,18 @@ struct JpegChunk {
}; };
class Esp32Camera : public Camera { 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: private:
struct FrameBuffer { struct FrameBuffer {
uint8_t *data = nullptr; uint8_t *data = nullptr;
@ -41,6 +54,10 @@ private:
std::string explain_token_; std::string explain_token_;
std::thread encoder_thread_; std::thread encoder_thread_;
// [T04] 采集互斥锁face_track 和 MCP 拍照共享 V4L2 DQBUF 单槽
// 使用 FreeRTOS 信号量(非 std::mutex以获得 timeout 语义
SemaphoreHandle_t capture_mutex_ = nullptr;
public: public:
Esp32Camera(const esp_video_init_config_t& config); Esp32Camera(const esp_video_init_config_t& config);
~Esp32Camera(); ~Esp32Camera();
@ -51,6 +68,24 @@ public:
virtual bool SetHMirror(bool enabled) override; virtual bool SetHMirror(bool enabled) override;
virtual bool SetVFlip(bool enabled) override; virtual bool SetVFlip(bool enabled) override;
virtual std::string Explain(const std::string& question); 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 成功返回 truestreaming 未启动或 ioctl 失败返回 false
bool ProbeFrameCapture(int64_t* elapsed_us);
// [T04] 人脸检测用帧采集:超短 timeout10ms拿不到锁则跳帧
// 语义:人脸检测允许丢帧,拍照不允许丢
// 成功返回 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 #endif // ndef CONFIG_IDF_TARGET_ESP32

179
main/face_tracker.cc Normal file
View File

@ -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 <esp_heap_caps.h>
#include <esp_log.h>
#include <esp_timer.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <list>
#include <new>
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<Esp32Camera*>(Board::GetInstance().GetCamera());
if (!cam) {
continue;
}
Esp32Camera::FrameRef f;
if (!cam->CaptureForDetection(&f)) {
// [T04 策略] 拿不到 mutexMCP 拍照中)或 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

24
main/face_tracker.h Normal file
View File

@ -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

View File

@ -24,7 +24,10 @@ dependencies:
78/xiaozhi-fonts: ~1.5.5 78/xiaozhi-fonts: ~1.5.5
espressif/led_strip: ~3.0.1 espressif/led_strip: ~3.0.1
espressif/esp_codec_dev: ~1.5 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/button: ~4.1.3
espressif/knob: ^1.0.0 espressif/knob: ^1.0.0
espressif/esp_video: espressif/esp_video:
@ -54,6 +57,18 @@ dependencies:
espressif/adc_battery_estimation: ^0.2.0 espressif/adc_battery_estimation: ^0.2.0
espressif/esp_new_jpeg: ^0.6.1 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.0registry 版本约束),升级到 ~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 # SenseCAP Watcher Board
wvirgil123/sscma_client: wvirgil123/sscma_client:
version: 1.0.2 version: 1.0.2

View File

@ -1,7 +1,14 @@
#include "uart_component.h" #include "uart_component.h"
#include "freertos/FreeRTOS.h" #include "freertos/FreeRTOS.h"
#include "freertos/task.h" #include "freertos/task.h"
#include "freertos/semphr.h"
#include <string.h> #include <string.h>
#include <stdio.h>
// T07: UART TX 全局互斥锁
// 保护所有 uart_write_bytes 调用,防止 face_tracker 任务与 application 任务并发
// 写入造成帧交织RESEARCH A3
static SemaphoreHandle_t s_uart_tx_mutex = nullptr;
// 初始化 ESP32 → RP2040 的 UART 通信 // 初始化 ESP32 → RP2040 的 UART 通信
// 波特率 1152008 数据位无校验1 停止位,无流控 // 波特率 1152008 数据位无校验1 停止位,无流控
@ -17,22 +24,52 @@ void uart_init_component() {
// GPIO17=TX发送到 RP2040 的 GP5/RXGPIO18=RX接收 RP2040 的 GP4/TX // GPIO17=TX发送到 RP2040 的 GP5/RXGPIO18=RX接收 RP2040 的 GP4/TX
uart_set_pin(UART_PORT_NUM, TXD_PIN, RXD_PIN, UART_PIN_NO_CHANGE, UART_PIN_NO_CHANGE); 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); 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末尾自动添加 \r\n
// RP2040 的 main.py 通过 coms.esp_read() 按 \n 分割解析 // RP2040 的 main.py 通过 coms.esp_read() 按 \n 分割解析
// 支持的状态字符串idle / listening / speaking / thinking / neutral / happy 等 // 支持的状态字符串idle / listening / speaking / thinking / neutral / happy 等
// T07: 加锁,防与 uart_send_face 并发撕包
void uart_send_string(const char* str) { 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, str, strlen(str));
uart_write_bytes(UART_PORT_NUM, "\r\n", 2); uart_write_bytes(UART_PORT_NUM, "\r\n", 2);
if (s_uart_tx_mutex != nullptr) {
xSemaphoreGive(s_uart_tx_mutex);
}
} }
// 发送说话开始信号预留接口RP2040 当前未使用) // 发送说话开始信号预留接口RP2040 当前未使用)
// 注意:经由 uart_send_string 间接加锁
void uart_signal_start() { void uart_signal_start() {
uart_send_string("[SPEAK_START]\n"); uart_send_string("[SPEAK_START]\n");
} }
// 发送说话停止信号预留接口RP2040 当前未使用) // 发送说话停止信号预留接口RP2040 当前未使用)
// 注意:经由 uart_send_string 间接加锁
void uart_signal_stop() { void uart_signal_stop() {
uart_send_string("[SPEAK_STOP]\n"); uart_send_string("[SPEAK_STOP]\n");
} }
// T07: 发送人脸检测坐标到 RP2040
// 格式:"face:<x>,<y>\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);
}

View File

@ -18,3 +18,15 @@ void uart_send_string(const char* str);
void uart_signal_start(); void uart_signal_start();
// 发送说话停止信号 // 发送说话停止信号
void uart_signal_stop(); void uart_signal_stop();
// 发送人脸检测坐标,格式:"face:<x>,<y>\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