Phase 01 调试迭代: OV3660 人脸检测集成 + 23 条踩坑经验汇总

## 代码变更

### main/application.cc
修复 T01 probe 日志 %lld 格式 bug(改用 %lu + unsigned long)

### main/boards/common/esp32_camera.cc
- 修复 DVP V4L2 单 buffer 导致 DMA 饥饿:req.count 从 1 改为 2
- 修复 [T01] Probe 日志 elapsed=ldus 显示问题(同上格式修复)

### main/face_tracker.cc
多轮迭代:
- 新增 frame debug 诊断日志(打印 top-left/center 16B + zero_bytes 统计)
- pix_type 尝试路径:YUYV → RGB565LE → RGB565BE → YUYV → RGB888(手动转换)
- 手动实现 BT.601 公式 YUYV→RGB888 转换,绕过 ImagePreprocessor 黑盒
- face_tracker 任务从 Core 0 切换到 Core 1,避让 RMT/LED 死锁
- 新增 INFO 级限频日志(每秒 1 条 face 检测记录)
- 修复推理时长日志 %lld 格式 bug
- 连续 3 秒无人脸时打印 no face detected

### main/idf_component.yml
esp_video 升级 1.3.1 → ~1.4.1(手动 patch 修 xclk_freq bug)

### partitions/v2/16m.csv
OTA 分区扩容:3.94MB → 5MB,assets 缩到 5.875MB,支持 4.23MB 固件

### docs/phase-01-face-tracking/PROGRESS.md
更新 Phase 01 执行日志,记录实机调试细节

## 文档更新

### Coglet项目分析与开发指南.md 新增第六点五节

完整记录本轮调试的 23 个踩坑,分为:
1. 编译/配置类(5 个):板级重置、依赖冲突、bootloader 缓存、%lld 格式、xclk_freq bug
2. 摄像头数据链路(5 个):sensor driver 启用、V4L2 buffer 数量、分区扩容、镜头保护膜、光照
3. esp-dl 人脸检测(3 个):MSR letterbox 伪影、ESPDET OOD 默认输出、字节序判断
4. 任务调度(3 个):WDT 崩溃、GDMA ISR 崩溃、弱符号链接
5. RP2040 端(4 个):idle 回中、坐标累加撞限位、mpremote 阻塞、两分支代码差异
6. 硬件(3 个):飞线验证、360° 舵机误用、烧录生效验证

附调试方法论 6 条 + 未解决遗留问题 3 条

## 已解决问题

-  ESP-IDF 编译链路(依赖/分区/格式)
-  ESP32 + RP2040 端到端协议(face:x,y UART)
-  WDT 崩溃(face_tracker 切到 Core 1)
-  RP2040 眼球回中机制(idle 时回正)
-  V4L2 双 buffer(DMA 数据更新正常)

## 遗留问题(待解决)

-  face 检测 box 固定伪激活(无论 pix_type / 画面内容 / 模型选择)
-  GDMA ISR 每 ~30s 触发 InstrFetchProhibited 崩溃
- ⚠️ 端到端验收:眼球未真正跟随人脸

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

View File

@ -585,6 +585,90 @@ MicroPython 固件刷入方式与摄像头版本相同(参见 4.6),但 **R
--- ---
## 六点五、Phase 01 单摄像头人脸追踪 踩坑经验2026-04-17 ~ 04-20
> **背景**:尝试用 ESP32 上的 OV3660 摄像头做人脸检测,通过 UART 发送人脸坐标给 RP2040驱动眼球/YAW 舵机追踪。详细规划/执行文档见 [docs/phase-01-face-tracking/](docs/phase-01-face-tracking/)。
>
> **现状**代码骨架全部完成15 个任务 T01-T15ESP32 端 + RP2040 端端到端联通但摄像头帧数据解析仍有问题box 固定伪激活),未完全通过验收。
### 6.5.1 编译 / 配置类踩坑
| # | 坑 | 原因 | 解决方案 |
|---|---|-----|---------|
| 1 | 板级配置被重置为 `BREAD_COMPACT_WIFI`(无摄像头版) | `idf.py fullclean` / `set-target` 可能触发 sdkconfig 重置到 defaults | 显式编辑 `sdkconfig` 确认 `CONFIG_BOARD_TYPE_BREAD_COMPACT_WIFI_CAM=y` |
| 2 | `esp-dl 3.3.0``esp-sr 2.2.x` 版本冲突 | esp-dl 需要 esp-dsp 1.7.0esp-sr 2.2 钉 esp-dsp 1.6.0 | 升级 `esp-sr``~2.3.1`(已切到 esp-dsp 1.7.0 |
| 3 | Bootloader CMake 缓存不匹配,编译报 `source does not match` | ESP-IDF 路径历史变更(如 `esp-idf/v5.4.2/esp-idf/``esp-idf/` | `rm -rf build/bootloader build/bootloader-prefix` 重新编译 |
| 4 | `ESP_LOGI``%lld` 输出变成字符串 `"ldus"` | ESP-IDF nano newlib 不完全支持 `%lld` | 改用 `%lu` + `(unsigned long)` 强转probe 用时 < 71 分钟uint32 足够 |
| 5 | `esp_video 1.3.1/1.4.1` 的 DVP 启动报 `xclk_freq is not set` | esp_video 源码 `dvp_video_device.c` 构造 `esp_cam_ctlr_dvp_config_t` 时缺失 `xclk_freq` 字段IDF 5.4.2 不支持 `external_xtal` | 升 esp_video 到 1.4.1 后**手动 patch** managed_components 源码,在 `#else` 分支加 `.xclk_freq = 20000000,` |
### 6.5.2 摄像头数据链路踩坑
| # | 坑 | 原因 | 解决方案 |
|---|---|-----|---------|
| 6 | `/dev/video2` 打不开errno=2 No such file | sdkconfig 中没有启用任何 sensor driver`CONFIG_CAMERA_OV3660` 默认 `n` | sdkconfig 启用对应摄像头:`CONFIG_CAMERA_OV3660=y` + 具体分辨率/格式 |
| 7 | V4L2 REQBUFS count 默认为 1DVP 路径DMA 饥饿陈旧数据 | `esp32_camera.cc``req.count = strcmp(...) == 0 ? 2 : 1;` 对 DVP 用单 buffer | 改为 `req.count = 2;` 让 DMA 有双缓冲持续更新 |
| 8 | 分区表 OTA 分区 3.94MB 容纳不下 4.23MB 固件 | 加了 esp-dl + human_face_detect 后固件膨胀 | 修改 `partitions/v2/16m.csv`ota_0/ota_1 各 5MBassets 缩到 5.875MB16MB 模组够用) |
| 9 | 摄像头输出画面全黑Y 值只有 0~8 | **镜头表面出厂保护膜未撕掉** | 用指甲抠边缘撕掉透明保护膜(极薄不易察觉) |
| 10 | 画面持续偏暗 | 室内光照不足 / 镜头指纹灰尘 | 白天窗边或明亮台灯下测试 |
### 6.5.3 esp-dl 人脸检测踩坑
| # | 坑 | 原因 | 解决方案 |
|---|---|-----|---------|
| 11 | MSR_S8_V1 模型固定在 `box=[0, y, 40, y+40]` 误识别 | 模型输入 160×1204:3摄像头输出 240×2401:1ImagePreprocessor letterbox 填充左右各 20 列灰条,模型在灰条上产生假激活 | 改用输入正方形的 `ESPDET_PICO_224_224_FACE`224×224 |
| 12 | ESPDET 模型仍然输出固定 box `[233, 158, 94, 239]`数学上不可能x1 > x2 | 怀疑模型对 out-of-distribution 输入 fallback 到默认 anchor | 尝试中:手动 YUYV→RGB888 转换 + 字节序验证 |
| 13 | 字节序判断反复RGB565LE / BE / YUYV 都试过 | OV3660 FORMAT_CTRL00=0x61 设置是"RGB565 byte-swapped",但实测数据更像 YUYV | 通过 `frame debug` 打印前 16 字节诊断,按多种格式对比解读 |
### 6.5.4 任务调度踩坑Core 亲和 & 崩溃)
| # | 坑 | 原因 | 解决方案 |
|---|---|-----|---------|
| 14 | 唤醒词检测后触发 LED 时崩溃(`Interrupt wdt timeout`| face_tracker 绑 Core 0esp-dl 推理占 150msRMT LED 驱动在同核抢不到 spinlock 超 300ms | face_tracker 改绑 **Core 1**(音频空闲时让出 CPU |
| 15 | GDMA ISR `InstrFetchProhibited` at PC=0x00000000 | esp_video 1.4.1 底层 DMA 回调指针变 NULL疑似 ESP-IDF 5.4.2 兼容性 bug | 未完全修复。降低 FPS 到 5 能推迟崩溃。待升级 ESP-IDF 或改用低级 esp_cam_ctlr API |
| 16 | 弱符号 `uart_send_face` 链接失败 | C++ 名字修饰问题 | `extern "C"` 包裹 weak 前置声明 + strong 定义,确保 C 链接名一致 |
### 6.5.5 RP2040 端踩坑
| # | 坑 | 原因 | 解决方案 |
|---|---|-----|---------|
| 17 | 进入 idle 后眼球卡在最后追踪位置不回中 | 原 `facetrack()` idle 时直接 return没有回中机制 | 新增 `_recenter_tracking_servos()`,在 `grove_active` 从 True→False 或首次进入 idle 时一次性回中到 90° |
| 18 | ESP32 持续发误识别坐标(-95/+40 等固定值) | 眼球持续累加偏移直到撞到机械限位30°/150° | 属 ESP32 侧 bug但 RP2040 侧加 `staticflag` 去重 + 回中机制缓解 |
| 19 | mpremote `could not enter raw repl` | Pico 主循环阻塞或端口被旧 monitor 占用 | 拔插 USB 复位 Pico → 立刻 `mpremote cp` 抢时间窗 |
| 20 | 两个分支 RP2040 代码不同 | `CogletESP` 分支 4 个 .py`coms.py``camera-version` 分支 3 个(无 `coms.py` | 使用时对应分支代码,不要混用 |
### 6.5.6 硬件踩坑
| # | 坑 | 原因 | 解决方案 |
|---|---|-----|---------|
| 21 | 3 根飞线35→14, 36→41, 37→42是否到位 | CogNog V1.0 PCB 设计缺陷PSRAM 和摄像头 D7/VSYNC/HREF 冲突 | 万用表测导通,确保原 GPIO 35/36/37 与摄像头断开,新 GPIO 14/41/42 接通 |
| 22 | 舵机堵转、发烫、齿轮刺耳声 | 误用 **360° 连续旋转舵机**MG90S 360° 版外观和 180° 一样) | 更换为 **KPower M0090****MG90S 180° 金属齿轮版**(详见 4.9 节) |
| 23 | 烧录固件后 `Compile time` 仍是旧版 | 用 IDE 内置烧录工具可能烧到错误路径 / 没完整烧录 | 改用命令行 `idf.py -p /dev/cu.usbmodemXXX flash monitor`,烧完立即 monitor 看 `Compile time` 验证 |
### 6.5.7 调试方法论
| 方法 | 用途 |
|------|-----|
| **frame debug 打印前 16B 字节值** | 按多种格式YUYV / RGB565LE / RGB565BE对比解读判断 sensor 实际输出 |
| **zero_bytes 比例统计** | 判断画面亮度(>30% 大概率镜头遮挡或极暗;<5% 画面正常 |
| **box 数学合法性** | `box[0] > box[2]``box[2] == width` 这种异常值说明模型崩了OOD 默认输出) |
| **对比不同画面输入下 box 是否变化** | 遮住摄像头 / 白墙 / 人脸 → 如果 box 完全一致,一定是模型输入路径异常 |
| **Compile time 验证烧录生效** | 烧录后 monitor 看 `I (xxx) app_init: Compile time: ...` 必须是最新编译时间 |
| **xtensa-esp32s3-elf-addr2line** | 把 crash backtrace 的地址转成源码文件:行号 |
### 6.5.8 Phase 01 未解决问题(遗留)
1. ❌ **box 固定伪激活**无论遮住摄像头、白墙、真实人脸box 都稳定输出 `[233, 158, 94, 239]`(或 MSR 模型的 `[0, *, 40, *]`)。已验证:
- pix_type 不是根因YUYV / RGB565LE / RGB565BE / RGB888 手动转换都不行)
- 双 buffer 修复了数据更新问题,但 box 仍固定
- 模型切换MSR → ESPDET改变了 box 坐标但仍固定
- 推断:模型对某种 out-of-distribution 输入 fallback 到默认 anchor
2. ❌ **GDMA ISR 崩溃**:每 ~30 秒触发一次 `InstrFetchProhibited` at PC=0x00000000属 esp_video 1.4.1 / ESP-IDF 5.4.2 底层 bug
3. ⚠️ **端到端未验收**RP2040 端 Bug 3回中机制已修但因 ESP32 侧 box 仍固定,整条链路的"眼球跟随真实人脸"未通过
---
## 七、参考资源 ## 七、参考资源
| 资源 | 地址 | | 资源 | 地址 |

View File

@ -12,9 +12,9 @@
- [x] T05 face_tracker.{h,cc} 骨架 + CMake 条件编译 - [x] T05 face_tracker.{h,cc} 骨架 + CMake 条件编译
- [x] T06 集成 HumanFaceDetect 推理 + 坐标归一化(代码部分;实测待 T12 - [x] T06 集成 HumanFaceDetect 推理 + 坐标归一化(代码部分;实测待 T12
- [x] T07 uart_send_face + uart mutex - [x] T07 uart_send_face + uart mutex
- [ ] T08 RP2040 parse_face + static 去重 - [x] T08 RP2040 parse_face + static 去重
- [ ] T09 RP2040 main.py incoming_commands 识别 face: - [x] T09 RP2040 main.py incoming_commands 识别 face:
- [ ] T10 RP2040 facetrack() 改造D-07 idle return - [x] T10 RP2040 facetrack() 改造D-07 idle return
- [ ] T11 application.cc 接入 face_tracker_start - [ ] T11 application.cc 接入 face_tracker_start
- [ ] T12 端到端联调 - [ ] T12 端到端联调
- [ ] T13 性能调优 - [ ] T13 性能调优
@ -107,3 +107,46 @@
T11 接入后会自动拉入 uart_send_face 的 strong 实现。 T11 接入后会自动拉入 uart_send_face 的 strong 实现。
- 未添加 test hookPLAN DoD 中提到的 `uart_send_face(42,-30)` 临时调用), - 未添加 test hookPLAN DoD 中提到的 `uart_send_face(42,-30)` 临时调用),
留给 T12 端到端联调时用真实 face_tracker 数据验证 留给 T12 端到端联调时用真实 face_tracker 数据验证
- [x] T08 完成2026-04-20 - 修改文件: /Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/coms.py
- `Comms.__init__` 末尾新增三个成员:
* `self.last_face_offset = None`ESP32 人脸坐标最新值main.py 消费后清空)
* `self.last_face_raw = None`static 去重用的"上一次原始坐标"
* `self.FACE_STATIC_THRESHOLD = 3`±3 像素阈值,经验值)
- 类内新增 `parse_face(line)` 方法:
* 兼容 `bytes` / `str` 输入(自动 decode + strip防御性处理
* 非 `face:` 前缀或解析失败返回 None不影响 staticflag
* 与 `last_face_raw` 对比:差异 ≤ 3 像素 → `staticflag = True`;否则 → `staticflag = False` 并更新 `last_face_raw`
* 首次收到坐标视为"非静态"(确保眼球初始化时会响应)
- **未新增** 独立的 `read_face()` 方法PLAN T08 规范只要求 `parse_face` + `last_face_offset`,不要求单独的 UART 读取方法——ESP32 UART 数据仍由 `esp_read()` 统一读入 `rx_buffer`main.py 遍历 `commands` 时逐个调 `parse_face`(见 T09。如果单独再提供 `read_face()` 会导致 UART 缓冲区被两个方法争抢,造成行撕裂。
- Python 语法检查:`python3 -c "import ast; ast.parse(...)"` 通过
- [x] T09 完成2026-04-20 - 修改文件: /Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/main.py
- 主循环 `for data in incoming_commands` 分支改造:
* 优先调 `external.parse_face(data)`,成功则设 `external.last_face_offset`
`animation.grove_active = True``animation.grove_last_seen = time.ticks_ms()``continue`
* 否则走原 `action_map` / `state_map` 分发(完全不影响现有 state 指令行为)
- `grove_active` / `grove_last_seen` 在收到 `face:` 消息时立即 set True即时响应与 T10 的 `facetrack()` 兜底刷新配合,不产生状态污染
- Python 语法检查通过
- [x] T10 完成2026-04-20 - 修改文件: /Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/main.py
- `facetrack()` 按 PLAN T10 v1.1 完整重构,执行顺序:
1. **始终读 Grove**`grove_offset = external.grove_read()`):即便无 Grove 硬件也要消费 UART 防止缓冲区溢出
2. **读 ESP32 face 数据**`esp_offset = external.last_face_offset`),消费后立即 `last_face_offset = None`
3. **数据源优先级**Grove 有效时用 Grove否则 fallback 到 ESP32
4. **D-07 修复**`if animation.current_state == "idle": return`idle 下只消费数据不驱动舵机
5. 非 idle 分支:原有 eyl/eyr/pit/yaw 的 scale + set_target 逻辑一字不动
- **BLOCKER #2 修复**`staticflag` 不再在 `facetrack()` 硬编码设置——完全由 T08 `parse_face()` / 原有 `grove_read()` 各自管理
- **grove_active 合并策略**T09 在消息到达时 set True实时T10 兜底在 `facetrack()` 里根据本帧最终 offset 再次刷新并处理 3 秒超时。两边都 set 到"当前时间戳"或 set False语义一致避免 BLOCKER #2 所描述的"双处更新导致污染"问题——只要 T09 先 set 到 TrueT10 进入时 `offset` 非空也会刷到同一时间戳,不会发生回退。
- Python 语法检查通过
- [!] T10 偏差说明(轻度):
- PLAN v1.1 在 T10 里写了"grove_active/grove_last_seen **不在本任务主动 set True**",但 `facetrack()` 在 Grove 硬件活跃时仍需要把 Grove 自身的 offset 反映到 grove_active 时间戳——本次实现在 `facetrack()` 里保留了"`if offset: grove_active=True`"写法,原因是 T09 只在收到 ESP32 `face:` 消息时 set True**不处理 Grove 来源**。如果严格按 PLAN 字面删掉Grove 模式下 `grove_active` 永远为 False3 秒超时总是会触发,自动动画会不停抢夺 Grove 追踪权。
- 处理方式T09 设 Grove_active 的职责是"ESP32 数据源"T10 保留的是"Grove 数据源"——两边是 OR 关系而非重复更新BLOCKER #2 所警告的是"两边都 set 到同一数据源",这里不是)。已在代码注释中记录。
- 此偏差属于 Rule 2补完 PLAN 遗漏的 Grove 分支),不需要用户决策。
- [x] 批次 4 (T08/T09/T10) Python 语法检查结果:
- `coms.py` OK
- `main.py` OK
- `animation.py` OK未改动做回归确认

View File

@ -25,6 +25,12 @@
#include "boards/common/esp32_camera.h" #include "boards/common/esp32_camera.h"
#endif #endif
// [T11] 人脸追踪任务启动接口Phase 01
// 三重保护Kconfig + CMake 排除 + 代码层 #if 守卫;此处 include 也用同守卫避免非 S3 目标报错
#if defined(CONFIG_XIAOZHI_ENABLE_FACE_TRACKING) && defined(CONFIG_IDF_TARGET_ESP32S3)
#include "face_tracker.h"
#endif
#define TAG "Application" #define TAG "Application"
@ -72,6 +78,11 @@ Application::Application() {
} }
Application::~Application() { Application::~Application() {
// [T11] 请求停止人脸检测任务(异步,任务会在下一帧自行退出)
#if defined(CONFIG_XIAOZHI_ENABLE_FACE_TRACKING) && defined(CONFIG_IDF_TARGET_ESP32S3)
face_tracker_stop();
#endif
if (clock_timer_handle_ != nullptr) { if (clock_timer_handle_ != nullptr) {
esp_timer_stop(clock_timer_handle_); esp_timer_stop(clock_timer_handle_);
esp_timer_delete(clock_timer_handle_); esp_timer_delete(clock_timer_handle_);
@ -558,14 +569,19 @@ void Application::Start() {
if (cam) { if (cam) {
int64_t elapsed = 0; int64_t elapsed = 0;
bool ok = cam->ProbeFrameCapture(&elapsed); bool ok = cam->ProbeFrameCapture(&elapsed);
ESP_LOGI("T01_Probe", "V4L2 probe result=%d elapsed=%lldus", ESP_LOGI("T01_Probe", "V4L2 probe result=%d elapsed=%luus",
ok, (long long)elapsed); ok, (unsigned long)elapsed);
} else { } else {
ESP_LOGW("T01_Probe", "no camera instance (board.GetCamera() returned null or non-Esp32Camera)"); ESP_LOGW("T01_Probe", "no camera instance (board.GetCamera() returned null or non-Esp32Camera)");
} }
} }
#endif #endif
// [T11] 启动人脸检测任务Kconfig 未开启 / 非 S3 目标时本段不编译)
#if defined(CONFIG_XIAOZHI_ENABLE_FACE_TRACKING) && defined(CONFIG_IDF_TARGET_ESP32S3)
face_tracker_start();
#endif
SystemInfo::PrintHeapStats(); SystemInfo::PrintHeapStats();
SetDeviceState(kDeviceStateIdle); SetDeviceState(kDeviceStateIdle);

View File

@ -280,8 +280,11 @@ Esp32Camera::Esp32Camera(const esp_video_init_config_t& config) {
#endif #endif
// 申请缓冲并mmap // 申请缓冲并mmap
// [2026-04-20 修复] 原 DVP 单 buffer 导致 face_tracker 每次 DQBUF 拿到
// 的都是同一帧陈旧数据DMA 无 buffer 可写),模型输出"固定伪激活"。
// 改为双 buffer 让 DMA 始终有空 buffer 可写,保证每次 DQBUF 取到新帧。
struct v4l2_requestbuffers req = {}; struct v4l2_requestbuffers req = {};
req.count = strcmp(video_device_name, ESP_VIDEO_MIPI_CSI_DEVICE_NAME) == 0 ? 2 : 1; req.count = 2;
req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
req.memory = V4L2_MEMORY_MMAP; req.memory = V4L2_MEMORY_MMAP;
if (ioctl(video_fd_, VIDIOC_REQBUFS, &req) != 0) { if (ioctl(video_fd_, VIDIOC_REQBUFS, &req) != 0) {
@ -420,8 +423,10 @@ bool Esp32Camera::ProbeFrameCapture(int64_t* elapsed_us) {
} }
int64_t t1 = esp_timer_get_time(); int64_t t1 = esp_timer_get_time();
if (elapsed_us) *elapsed_us = t1 - t0; if (elapsed_us) *elapsed_us = t1 - t0;
ESP_LOGI(TAG, "[T01] Probe 成功bytesused=%u elapsed=%lldus", // 修复ESP-IDF nano newlib printf 不完全支持 %lld改为 %lu + 强制 uint32
(unsigned)bytes_used, (long long)(t1 - t0)); // probe 时间 < 100ms << uint32 上限(~71 分钟),完全安全
ESP_LOGI(TAG, "[T01] Probe 成功bytesused=%u elapsed=%luus",
(unsigned)bytes_used, (unsigned long)(t1 - t0));
return true; return true;
} }

View File

@ -31,6 +31,32 @@ static float s_last_fps = 0.0f;
// T07 完成后该弱符号被真实实现覆盖,无需改动本文件 // T07 完成后该弱符号被真实实现覆盖,无需改动本文件
extern "C" __attribute__((weak)) void uart_send_face(int x_offset, int y_offset); extern "C" __attribute__((weak)) void uart_send_face(int x_offset, int y_offset);
// YUYV → RGB888 手动转换(每 4 字节 YUYV 生成 2 像素 6 字节 RGB
// 公式BT.601R = Y + 1.402*(V-128); G = Y - 0.344*(U-128) - 0.714*(V-128); B = Y + 1.772*(U-128)
static inline void yuyv_to_rgb888_line(const uint8_t* yuyv, uint8_t* rgb, int pixels) {
for (int i = 0; i < pixels; i += 2) {
int y1 = yuyv[0];
int u = yuyv[1] - 128;
int y2 = yuyv[2];
int v = yuyv[3] - 128;
yuyv += 4;
// 像素 1
int r1 = y1 + (359 * v) / 256;
int g1 = y1 - (88 * u + 183 * v) / 256;
int b1 = y1 + (454 * u) / 256;
// 像素 2
int r2 = y2 + (359 * v) / 256;
int g2 = y2 - (88 * u + 183 * v) / 256;
int b2 = y2 + (454 * u) / 256;
*rgb++ = (uint8_t)(r1 < 0 ? 0 : r1 > 255 ? 255 : r1);
*rgb++ = (uint8_t)(g1 < 0 ? 0 : g1 > 255 ? 255 : g1);
*rgb++ = (uint8_t)(b1 < 0 ? 0 : b1 > 255 ? 255 : b1);
*rgb++ = (uint8_t)(r2 < 0 ? 0 : r2 > 255 ? 255 : r2);
*rgb++ = (uint8_t)(g2 < 0 ? 0 : g2 > 255 ? 255 : g2);
*rgb++ = (uint8_t)(b2 < 0 ? 0 : b2 > 255 ? 255 : b2);
}
}
static void face_tracker_task(void* arg) { static void face_tracker_task(void* arg) {
(void)arg; (void)arg;
// 等待摄像头 ISP 预热 + 视频流启动稳定 // 等待摄像头 ISP 预热 + 视频流启动稳定
@ -38,6 +64,18 @@ static void face_tracker_task(void* arg) {
ESP_LOGI(TAG, "face_tracker task started on core %d", xPortGetCoreID()); ESP_LOGI(TAG, "face_tracker task started on core %d", xPortGetCoreID());
// [2026-04-20 重大修复] 分配 PSRAM RGB888 缓冲区,手动 YUYV→RGB888 转换
// 绕过 esp-dl ImagePreprocessor 的 YUYV 路径(疑似产生固定激活 bug
// 240*240*3 = 172800 字节PSRAM 8MB 完全够
constexpr size_t RGB_SIZE = 240 * 240 * 3;
uint8_t* rgb_buf = (uint8_t*)heap_caps_malloc(RGB_SIZE, MALLOC_CAP_SPIRAM);
if (!rgb_buf) {
ESP_LOGE(TAG, "分配 RGB888 缓冲失败");
vTaskDelete(NULL);
return;
}
ESP_LOGI(TAG, "RGB888 转换缓冲已分配 %u bytes", (unsigned)RGB_SIZE);
// 构造检测器:默认 model_type 由 CONFIG_DEFAULT_HUMAN_FACE_DETECT_MODEL 决定 // 构造检测器:默认 model_type 由 CONFIG_DEFAULT_HUMAN_FACE_DETECT_MODEL 决定
// lazy_load=true默认以减少启动期内存瞬时占用 // lazy_load=true默认以减少启动期内存瞬时占用
auto* detector = new(std::nothrow) HumanFaceDetect(); auto* detector = new(std::nothrow) HumanFaceDetect();
@ -67,6 +105,9 @@ static void face_tracker_task(void* arg) {
TickType_t last_wake = xTaskGetTickCount(); TickType_t last_wake = xTaskGetTickCount();
int hit = 0, miss = 0; int hit = 0, miss = 0;
int64_t last_report_us = esp_timer_get_time(); int64_t last_report_us = esp_timer_get_time();
// 实时日志限频:每秒最多 1 条INFO 级别便于排查)
int64_t last_detail_log_us = 0;
int miss_streak = 0; // 连续 miss 计数
while (!s_stop) { while (!s_stop) {
vTaskDelayUntil(&last_wake, period); vTaskDelayUntil(&last_wake, period);
@ -82,13 +123,48 @@ static void face_tracker_task(void* arg) {
continue; continue;
} }
// 组装 esp-dl 图像描述符 // [Bug 1 诊断] 首次进入循环时,打印前 32 字节 + 中心像素 + 统计,判断数据性质
// RESEARCH Pitfall A1先假定 YUYV若首轮 score 低于 0.5 可改 RGB565LE决策点 D-B // 全零 → 摄像头无数据;规律 → 字节序/格式问题;随机 → 正常但模型看不懂
static bool debug_dumped = false;
if (!debug_dumped && f.data && f.len >= 32) {
debug_dumped = true;
const uint8_t* d = (const uint8_t*)f.data;
ESP_LOGI(TAG, "frame debug: size=%u w=%u h=%u len=%u",
(unsigned)f.width * f.height * 2, f.width, f.height, (unsigned)f.len);
// 打印左上角 16 字节 + 中心附近 16 字节
size_t center = (f.width * (f.height / 2) + f.width / 2) * 2;
if (center + 16 <= f.len) {
ESP_LOGI(TAG, "top-left 16B: %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x",
d[0],d[1],d[2],d[3],d[4],d[5],d[6],d[7],d[8],d[9],d[10],d[11],d[12],d[13],d[14],d[15]);
ESP_LOGI(TAG, "center 16B: %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x %02x",
d[center],d[center+1],d[center+2],d[center+3],d[center+4],d[center+5],d[center+6],d[center+7],
d[center+8],d[center+9],d[center+10],d[center+11],d[center+12],d[center+13],d[center+14],d[center+15]);
}
// 统计:零字节比例(判断摄像头是否真有数据)
size_t zero_cnt = 0;
for (size_t i = 0; i < f.len; i++) if (d[i] == 0) zero_cnt++;
ESP_LOGI(TAG, "zero bytes: %u / %u (%.1f%%)",
(unsigned)zero_cnt, (unsigned)f.len, 100.0f * zero_cnt / f.len);
}
// [2026-04-20 重大修复] 手动 YUYV → RGB888 转换,绕过 esp-dl 预处理黑盒
// 以前img.pix_type = YUYV让 ImagePreprocessor 内部做 YUV→RGB但它产生固定激活
// 现在:先转成 RGB888 喂给模型pix_type 标 RGB888消除预处理不确定性
{
const uint8_t* src = (const uint8_t*)f.data;
uint8_t* dst = rgb_buf;
for (uint16_t row = 0; row < f.height; row++) {
yuyv_to_rgb888_line(src, dst, f.width);
src += f.width * 2; // YUYV 每像素 2 字节
dst += f.width * 3; // RGB888 每像素 3 字节
}
}
dl::image::img_t img{}; dl::image::img_t img{};
img.data = (void*)f.data; img.data = (void*)rgb_buf;
img.width = f.width; img.width = f.width;
img.height = f.height; img.height = f.height;
img.pix_type = dl::image::DL_IMAGE_PIX_TYPE_YUYV; img.pix_type = dl::image::DL_IMAGE_PIX_TYPE_RGB888;
int64_t t0 = esp_timer_get_time(); int64_t t0 = esp_timer_get_time();
auto& results = detector->run(img); auto& results = detector->run(img);
@ -97,10 +173,17 @@ static void face_tracker_task(void* arg) {
// 立即归还 V4L2 缓冲,避免 face_track 占用时间长 // 立即归还 V4L2 缓冲,避免 face_track 占用时间长
cam->ReleaseDetectionFrame(f); cam->ReleaseDetectionFrame(f);
int64_t now_us = esp_timer_get_time();
if (results.empty()) { if (results.empty()) {
miss++; miss++;
miss_streak++;
// 连续 3 秒无人脸时提示一次(按默认 FPS=10 折算 ~30 帧)
if (miss_streak == CONFIG_XIAOZHI_FACE_TRACKING_FPS * 3) {
ESP_LOGI(TAG, "no face detected in last 3s");
}
} else { } else {
hit++; hit++;
miss_streak = 0;
// PLAN 未明确排序策略esp-dl 内部 nms 后 list 顺序不稳定 // PLAN 未明确排序策略esp-dl 内部 nms 后 list 顺序不稳定
// 为健壮性,挑 score 最高的那个(避免多脸时摇摆) // 为健壮性,挑 score 最高的那个(避免多脸时摇摆)
const dl::detect::result_t* best = nullptr; const dl::detect::result_t* best = nullptr;
@ -121,8 +204,19 @@ static void face_tracker_task(void* arg) {
if (uart_send_face != nullptr) { if (uart_send_face != nullptr) {
uart_send_face(x_offset, y_offset); uart_send_face(x_offset, y_offset);
} }
ESP_LOGD(TAG, "face score=%.2f offset=(%d,%d) infer=%lldus", // INFO 级别实时日志,限频每秒 1 条避免刷屏
best->score, x_offset, y_offset, (long long)(t1 - t0)); // 修复:%lld 在 nano newlib 下输出异常,改为 %lu + uint32infer<2s 安全)
if (now_us - last_detail_log_us > 1000000LL) {
ESP_LOGI(TAG, "face: score=%.2f box=[%d,%d,%d,%d] offset=(%+d,%+d) infer=%lums",
best->score,
best->box[0], best->box[1], best->box[2], best->box[3],
x_offset, y_offset,
(unsigned long)((t1 - t0) / 1000));
last_detail_log_us = now_us;
}
// 高频详细日志保留为 LOGD需 idf.py monitor 按 Ctrl+T Y 切换为 DEBUG
ESP_LOGD(TAG, "face score=%.2f offset=(%d,%d) infer=%luus",
best->score, x_offset, y_offset, (unsigned long)(t1 - t0));
} }
// 每 10 秒汇报一次统计(加保底避免除零) // 每 10 秒汇报一次统计(加保底避免除零)
@ -140,6 +234,9 @@ static void face_tracker_task(void* arg) {
} }
delete detector; delete detector;
if (rgb_buf) {
heap_caps_free(rgb_buf);
}
ESP_LOGI(TAG, "face_tracker task exiting"); ESP_LOGI(TAG, "face_tracker task exiting");
s_handle = nullptr; s_handle = nullptr;
vTaskDelete(NULL); vTaskDelete(NULL);
@ -151,11 +248,14 @@ extern "C" void face_tracker_start(void) {
return; return;
} }
s_stop = false; s_stop = false;
// Core 0 + 优先级 2低于 LVGL / 音频,避免抢占主路径 // [2026-04-20 修复 WDT 崩溃] 原绑 Core 0 + 优先级 2 会导致:
// esp-dl 推理占 150ms → 同核的 RMT LED 驱动拿不到 spinlock 超过 300ms →
// 触发 Interrupt WDT → SetDeviceState 切换时点 LED 崩溃。
// 改绑到 Core 1WiFi/RMT/LED 在 Core 0音频在 Core 1 但只 speaking 时重载)。
// 栈 8KB给 esp-dl 推理留充足空间 // 栈 8KB给 esp-dl 推理留充足空间
BaseType_t ok = xTaskCreatePinnedToCore( BaseType_t ok = xTaskCreatePinnedToCore(
face_tracker_task, "face_track", face_tracker_task, "face_track",
8 * 1024, nullptr, 2, &s_handle, 0); 8 * 1024, nullptr, 2, &s_handle, 1);
if (ok != pdPASS) { if (ok != pdPASS) {
ESP_LOGE(TAG, "xTaskCreatePinnedToCore failed"); ESP_LOGE(TAG, "xTaskCreatePinnedToCore failed");
s_handle = nullptr; s_handle = nullptr;

View File

@ -30,8 +30,11 @@ dependencies:
espressif/esp-sr: ~2.3.1 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
# [Phase 01] 2026-04-20 升级1.3.1 在 S3 DVP 启动时少填 xclk_freq 字段
# 导致 VIDIOC_STREAMON 报 "xclk_freq is not set"(见 dvp_video_device.c:229-241
# 升级到 ~1.4.1 解决,如果需要 API 适配再改 esp32_camera.cc
espressif/esp_video: espressif/esp_video:
version: ==1.3.1 # for compatibility. update version may need to modify this project code. version: ~1.4.1
rules: rules:
- if: target not in [esp32] - if: target not in [esp32]
espressif/esp_image_effects: espressif/esp_image_effects:

View File

@ -1,8 +1,12 @@
# ESP-IDF Partition Table # ESP-IDF Partition Table
# [Phase 01] 2026-04-17 方案 A扩大 OTA 分区给 esp-dl 人脸检测模型留空间
# 原布局ota_0/ota_1 各 3.94MB0x3f0000assets 8MB
# 新布局ota_0/ota_1 各 5MB0x500000assets 缩到 5.875MB0x5e0000 = 6016K
# 总计0x20000 + 0x500000 + 0x500000 + 0x5e0000 = 0x1000000 = 16MB
# Name, Type, SubType, Offset, Size, Flags # Name, Type, SubType, Offset, Size, Flags
nvs, data, nvs, 0x9000, 0x4000, nvs, data, nvs, 0x9000, 0x4000,
otadata, data, ota, 0xd000, 0x2000, otadata, data, ota, 0xd000, 0x2000,
phy_init, data, phy, 0xf000, 0x1000, phy_init, data, phy, 0xf000, 0x1000,
ota_0, app, ota_0, 0x20000, 0x3f0000, ota_0, app, ota_0, 0x20000, 0x500000,
ota_1, app, ota_1, , 0x3f0000, ota_1, app, ota_1, , 0x500000,
assets, data, spiffs, 0x800000, 8M assets, data, spiffs, 0xa20000, 0x5e0000

1 # ESP-IDF Partition Table
2 # [Phase 01] 2026-04-17 方案 A:扩大 OTA 分区给 esp-dl 人脸检测模型留空间
3 # 原布局:ota_0/ota_1 各 3.94MB(0x3f0000),assets 8MB
4 # 新布局:ota_0/ota_1 各 5MB(0x500000),assets 缩到 5.875MB(0x5e0000 = 6016K)
5 # 总计:0x20000 + 0x500000 + 0x500000 + 0x5e0000 = 0x1000000 = 16MB
6 # Name, Type, SubType, Offset, Size, Flags
7 nvs, data, nvs, 0x9000, 0x4000,
8 otadata, data, ota, 0xd000, 0x2000,
9 phy_init, data, phy, 0xf000, 0x1000,
10 ota_0, app, ota_0, 0x20000, 0x3f0000, ota_0, app, ota_0, 0x20000, 0x500000,
11 ota_1, app, ota_1, , 0x3f0000, ota_1, app, ota_1, , 0x500000,
12 assets, data, spiffs, 0x800000, 8M assets, data, spiffs, 0xa20000, 0x5e0000