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:
parent
e61d8f2175
commit
e95d0c414e
92
docs/phase-01-face-tracking/GOAL.md
Normal file
92
docs/phase-01-face-tracking/GOAL.md
Normal file
@ -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,追踪流畅度可能下降
|
||||
1200
docs/phase-01-face-tracking/PLAN.md
Normal file
1200
docs/phase-01-face-tracking/PLAN.md
Normal file
File diff suppressed because it is too large
Load Diff
189
docs/phase-01-face-tracking/PLAN_CHECK.md
Normal file
189
docs/phase-01-face-tracking/PLAN_CHECK.md
Normal 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 删除重复更新代码 + 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**,进入执行阶段。
|
||||
109
docs/phase-01-face-tracking/PROGRESS.md
Normal file
109
docs/phase-01-face-tracking/PROGRESS.md
Normal 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 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 数据验证
|
||||
713
docs/phase-01-face-tracking/RESEARCH.md
Normal file
713
docs/phase-01-face-tracking/RESEARCH.md
Normal 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 总推理耗时 ~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 <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 到 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,理论上可用两核
|
||||
@ -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}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -18,6 +18,13 @@
|
||||
#include <font_awesome.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"
|
||||
|
||||
|
||||
@ -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<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();
|
||||
SetDeviceState(kDeviceStateIdle);
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -9,6 +9,7 @@
|
||||
|
||||
#include <freertos/FreeRTOS.h>
|
||||
#include <freertos/queue.h>
|
||||
#include <freertos/semphr.h>
|
||||
|
||||
#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
|
||||
179
main/face_tracker.cc
Normal file
179
main/face_tracker.cc
Normal 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 策略] 拿不到 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
|
||||
24
main/face_tracker.h
Normal file
24
main/face_tracker.h
Normal 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
|
||||
@ -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
|
||||
|
||||
@ -1,7 +1,14 @@
|
||||
#include "uart_component.h"
|
||||
#include "freertos/FreeRTOS.h"
|
||||
#include "freertos/task.h"
|
||||
#include "freertos/semphr.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 通信
|
||||
// 波特率 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:<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);
|
||||
}
|
||||
|
||||
@ -18,3 +18,15 @@ void uart_send_string(const char* str);
|
||||
void uart_signal_start();
|
||||
// 发送说话停止信号
|
||||
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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user