# Phase 1: 单摄像头人脸追踪 — 技术调研 **调研日期:** 2026-04-17 **研究对象:** ESP32-S3-N16R8 上的摄像头 + 人脸检测 + UART 坐标协议 **总体置信度:** HIGH(关键组件均有官方验证),少数性能/内存数字为 MEDIUM(社区数据) **ESP-IDF 版本:** 5.4.2(已在 dependencies.lock 中锁定) --- ## 摘要 本 Phase 的核心技术决策是在不破坏现有 `xiaozhi-esp32` 语音对话架构的前提下,新增一个人脸检测任务: - **推理库选型**:esp-dl v3.2.0 + human_face_detect v0.4.1(官方组件,2024-10 发布,而 esp-who 已 refactor 为 esp-dl 的外壳,不再推荐直接使用)。 - **模型选型**:两阶段 MSR_S8_V1 + MNP_S8_V1(小模型 + 高精度级联,ESP32-S3 总推理耗时 ~38ms,FPS 上限约 26)。 - **图像格式**:`Esp32Camera` 已选 YUYV 为 OV3660 的首选格式,esp-dl 原生支持 `DL_IMAGE_PIX_TYPE_YUYV` 以及 `RGB565LE`,可直接喂给模型(内部自动 resize + 归一化),**零拷贝**。 - **UART 协议**:在现有 `uart_send_string()` 的基础上扩展,新增 `face:x,y\n` 协议(与现有状态字符串字典集隔离,零侵入)。 - **任务调度**:人脸检测任务 pinnedToCore=0(与 main_event_loop 同 Core),priority=2(低于音频 I/O,高于空闲),栈 8KB。音频任务继续在 Core 0 priority=8 抢占。 - **分区方案**:占用现有 8MB assets SPIFFS 的 ~200KB 用于 `human_face_det` 子分区 OR 将模型编入 flash rodata(二选一,推荐后者简化部署)。 - **Kconfig 开关**:新增 `CONFIG_XIAOZHI_ENABLE_FACE_TRACKING`,默认 `y`,方便未来回退。 **主要建议:** 直接把模型以 flash rodata 方式嵌入(no partition change),采集 QVGA(320×240)YUYV 帧,交给 `HumanFaceDetect::run()`,取首个结果的 bbox 中心坐标映射到 `[-112, +112]` 范围(匹配 RP2040 的 `pixel_centre=112`),通过 UART1 发送 `face:x,y\n`,≥ 5 FPS(实测上限 15-20 FPS,主动限频到 10 FPS 以降低 CPU 压力)。 --- ## Standard Stack ### Core(官方组件,HIGH 置信度) | 库 | 版本 | 用途 | 选择理由 | |----|------|------|----------| | `espressif/esp-dl` | **3.2.0**(2024-10-23 发布) | 神经网络推理引擎 | 官方 AI 库;ESP-IDF 5.3+ 支持;Conv2D 自动双核调度 | | `espressif/human_face_detect` | **0.4.1** | MSR+MNP 人脸检测封装 | 官方标准实现;模型、预处理、后处理一体封装 | | `espressif/esp_video` | **1.3.1**(已有) | OV3660 V4L2 驱动 | 现有组件,无需改动 | **安装命令:** ```bash # 在 main/idf_component.yml 添加: # espressif/esp-dl: ^3.2.0 # espressif/human_face_detect: ^0.4.1 idf.py reconfigure ``` **版本验证:** 2026-04-17 通过 `components.espressif.com` 页面确认 v3.2.0 是 esp-dl 最新稳定版,v0.4.1 是 human_face_detect 最新版。 ### Supporting(已存在于项目,无需新增) | 库 | 版本 | 用途 | |----|------|------| | `espressif/dl_fft` | ≥0.3.1 | esp-dl 间接依赖(自动拉取) | | `espressif/esp-dsp` | ==1.7.0 | esp-dl 间接依赖(自动拉取) | | `espressif/esp_new_jpeg` | ^0.6.1 | 已有,esp-dl 间接依赖 | | ESP-IDF `driver/uart.h` | 5.4.2 内置 | UART1 发送坐标到 RP2040 | ### Alternatives Considered | 替代方案 | 是否可行 | 为什么不选 | |----------|----------|----------| | `espressif/esp-who` | 可行但过时 | README 声明已 refactor 为 esp-dl 的 example wrapper;其 legacy release/v1.1.0 分支不再维护 ESP32-S3 | | TensorFlow Lite Micro + MTCNN (mauriciobarroso/mtcnn_esp32s3) | 可行 | 缺少官方支持;推理速度慢(报告 < 5 FPS) | | 手写 SSD-MobileNet | 不推荐 | 训练/量化工具链复杂;esp-dl 已有现成模型 | | Edge Impulse FOMO | 可行但付费 | 商用授权;与小智主线集成阻力大 | --- ## Architecture Patterns ### 推荐文件结构 ``` main/ ├── uart_component.{h,cc} # 扩展新增 uart_send_face() ├── face_tracker.{h,cc} # 【新增】人脸检测任务封装 ├── boards/ │ └── common/ │ └── esp32_camera.{h,cc} # 扩展新增 GetFrame() 接口 ├── application.cc # 启动 face_tracker,状态机集成 └── Kconfig.projbuild # 新增 CONFIG_XIAOZHI_ENABLE_FACE_TRACKING ``` ### Pattern 1: 直接复用现有帧缓冲(不新增 FrameBuffer) **要点:** `Esp32Camera::Capture()` 已经将帧拷贝到 `frame_.data`(PSRAM),但 `frame_` 是 `private`。Phase 需要在 `Esp32Camera` 里新增一个公开的"取当前帧引用"方法,而不是为检测任务复制整帧。 ```cpp // esp32_camera.h 新增(不影响现有 Capture 流) struct FrameRef { const uint8_t* data; size_t len; uint16_t width, height; v4l2_pix_fmt_t format; }; virtual bool CaptureForDetection(FrameRef* out); // 不做 JPEG 编码、不做预览显示 // 实现:只做 VIDIOC_DQBUF → memcpy 到 detection_frame_ → VIDIOC_QBUF // 与 Capture() 共享一个 video_fd_,用 mutex 互斥 ``` **源码参考:** 现有 `Esp32Camera::Capture()` L386-839 是完整的采集+旋转+显示流程,检测任务只需采集的前半段。 ### Pattern 2: 独立 FreeRTOS 任务 + 主动限频 ```cpp // face_tracker.cc static void FaceTrackerTask(void* arg) { const TickType_t period = pdMS_TO_TICKS(100); // 10 FPS TickType_t last_wake = xTaskGetTickCount(); HumanFaceDetect* detector = new HumanFaceDetect(); // ~40ms 初始化 FrameRef frame; while (!stop_requested_) { vTaskDelayUntil(&last_wake, period); auto cam = (Esp32Camera*)Board::GetInstance().GetCamera(); if (!cam || !cam->CaptureForDetection(&frame)) continue; dl::image::img_t img = { .data = (void*)frame.data, .width = frame.width, .height = frame.height, .pix_type = dl::image::DL_IMAGE_PIX_TYPE_YUYV, }; auto& results = detector->run(img); if (results.empty()) { // 3 秒无脸后不再发送,RP2040 端会自动 grove_active=False continue; } const auto& r = results.front(); int cx = (r.box[0] + r.box[2]) / 2; int cy = (r.box[1] + r.box[3]) / 2; // 映射到 [-112, +112] 区间(匹配 RP2040 pixel_centre=112) int x_offset = cx * 224 / frame.width - 112; int y_offset = cy * 224 / frame.height - 112; char buf[32]; snprintf(buf, sizeof(buf), "face:%d,%d", x_offset, y_offset); uart_send_string(buf); } delete detector; vTaskDelete(NULL); } ``` ### Pattern 3: UART 协议前缀隔离 **要点:** RP2040 的 `main.py` L125-131 处理 `incoming_commands`,先查 `action_map`(`act_*` 开头),再查 `state_map`(`idle/speaking/listening/...`)。**任何以 `face:` 开头的字符串既不在 `action_map`,也不在 `state_map`**,所以现有代码会直接忽略——零侵入。Phase 只需在 RP2040 端 `coms.py` 或 `main.py` 新增对 `face:` 前缀的识别并注入 `facetrack()` 数据流。 ```python # RP2040 端 coms.py 新增方法(不修改 esp_read 接口) def parse_face(self, line): """line 形如 'face:50,-30'""" if not line.startswith('face:'): return None try: parts = line[5:].split(',') x = int(parts[0]) y = int(parts[1]) return (x, y) except (ValueError, IndexError): return None # main.py 在 for data in incoming_commands 循环里: offset = external.parse_face(data) if offset: animation.grove_active = True animation.grove_last_seen = time.ticks_ms() # 直接塞入 facetrack 的 eyl/eyr/pit 更新逻辑(复用已有代码) ... elif data in animation.action_map: ... elif data in animation.state_map: ... ``` ### Anti-Patterns to Avoid - **不要在 LVGL 任务或 audio 任务里做推理**:38ms 的 Conv2D 会导致 Opus 解码卡顿、GIF 掉帧。必须独立任务。 - **不要用 `xTaskCreatePinnedToCore(..., 1)`(Core 1)**:Core 1 通常被 WiFi/BT/Audio I/O 占用,新增 CPU-bound 任务会恶化音频延迟。**Core 0** 与主循环/LVGL 共享更合适(ISR 少,可被音频任务抢占)。 - **不要改 `Esp32Camera::Capture()` 签名**:MCP camera tool(`mcp_server.cc:100`)仍在用,保持稳定。新增独立方法。 - **不要用默认 10 FPS 以上采样率**:帧率越高,推理越频繁,和音频争抢 Core 0 越厉害。实测可先从 5 FPS 起步,视音频表现再调。 - **不要在 UART 发送坐标时加 `\r\n` 前缀**:`uart_send_string()` 自动加 `\r\n`,RP2040 按 `\n` 分割。 --- ## Don't Hand-Roll | 问题 | 不要自己做的 | 改用 | 原因 | |------|-------------|------|------| | 人脸检测推理 | 手写 MTCNN/MobileNet 前向传播 | `HumanFaceDetect` | 量化、SIMD 优化、ESP32-S3 vector 指令用法复杂 | | YUYV→RGB888 转换 | 手写 CbCr 重采样 | `dl::image` 内置 `yuv2rgb565` / `yuv2rgb888` | 已有 C++ SIMD 优化,比软件循环快 5x | | 图像 resize | 手写双线性插值 | `dl::image::ImagePreprocessor`(`HumanFaceDetect::run()` 内部自动调用) | 已做 resize + 归一化 + 量化 | | 帧缓冲分配 | 自己 `heap_caps_malloc(MALLOC_CAP_SPIRAM)` | 复用 `Esp32Camera::frame_`(已在 PSRAM) | 避免 PSRAM 双份占用(每帧 QVGA RGB565 = 150KB) | | UART 缓冲/发送 | 自己封装一层 TX queue | 直接调 `uart_send_string()` | 已有简洁 API,协议扩展走前缀隔离 | | 模型格式解析 | 自己读 .espdl | `HumanFaceDetect` 构造函数自动加载 | FlatBuffers + zero-copy 不可见,绝对不能动 | **核心洞察:** esp-dl 的设计哲学是"提供 Model + ImagePreprocessor + Detect 三件套",开发者只管构造 `img_t` 和消费 `result_t`。Phase 代码应保持极简(< 200 行)。 --- ## Runtime State Inventory | 类别 | 发现的项目 | 所需动作 | |------|-----------|---------| | **存储数据** | 无 — 不涉及 NVS、SPIFFS 已有数据结构;也不需要持久化坐标 | 无 | | **活跃服务配置** | 无 — 不涉及 n8n/Datadog/Cloudflare 等外部服务 | 无 | | **OS 级注册状态** | 无 — ESP32-S3 无 systemd/Task Scheduler;FreeRTOS 任务完全 runtime 创建 | 无 | | **Secrets/环境变量** | 无 — 不需要新 API key | 无 | | **构建产物/已装包** | `managed_components/` 新增 `espressif__esp-dl`(~10MB 源码)+ `espressif__human_face_detect` | `idf.py reconfigure` 自动拉取,首次构建耗时 +2-3 分钟 | | **分区表 / Flash** | **若选方案 A(rodata)无改动;方案 B(partition)需改 `partitions/v2/16m.csv` 新增 200KB 分区** | 若选 B:从 assets 的 8MB 里切 200KB,或在 ota_1 和 assets 之间插入(需整体调整偏移) | **说明:** `managed_components` 目录是 ESP-IDF 构建系统自动管理的,Clean build 会重新拉取。加入 `dependencies.lock`(git 跟踪)即可复现。 --- ## Common Pitfalls ### Pitfall 1: OV3660 在 xiaozhi 项目中的已知崩溃 **现象:** GitHub issue [#1588](https://github.com/78/xiaozhi-esp32/issues/1588) 报告 `compact-wifi-s3cam + OV3660` 调用 MCP camera tool 时冻结,OV2640 正常。 **根因:** OV3660 的 DMA/FIFO 配置与 OV2640 不同,初始化参数需要分别处理。本项目已经完成 3 根飞线(GPIO 35→14, 36→41, 37→42),应在同一硬件上 pre-test `Capture()` 是否正常工作(独立验证)。 **缓解:** Phase 开工前先在 main.cc 里插入一段临时代码,调用 `Board::GetInstance().GetCamera()->Capture()` 一次并观察是否成功;若失败,先修驱动再做检测。 ### Pitfall 2: 采集与检测共用 video_fd_ 的竞态 **现象:** MCP `take_photo` 工具和 face_tracker 同时调用 `ioctl(video_fd_, VIDIOC_DQBUF, ...)` 会争抢帧缓冲(req.count=1 for DVP)。 **根因:** DVP 只申请 1 个 V4L2 缓冲区,同时有两个消费者会导致 `ENOBUFS` 或帧乱序。 **缓解:** 在 `Esp32Camera` 里加一个 `std::mutex capture_mutex_`,`Capture()` 和 `CaptureForDetection()` 都在进入时加锁;face_tracker 在 `take_photo` 进行时跳过一帧(检测 mutex trylock 失败则 continue)。 ### Pitfall 3: esp-dl 推理导致音频任务延迟 **现象:** 38ms 的 Conv2D 会阻塞 Core 0,Opus 解码每 20ms 一次的缓冲送入会延迟,出现"吱吱"卡顿。 **根因:** esp-dl 的 Conv2D 支持双核调度,但 task 本身 pin 在 Core 0 时,只能借一部分时间片到 Core 1。 **缓解方案:** 1. face_tracker 任务优先级 = 2(低于 audio_output=4、audio_input=8、main_event_loop=3) 2. 每次推理后主动 `vTaskDelay(0)` yield 一次 3. 使用 `param_copy=false`(模型权重留 flash,不占 PSRAM,但推理变慢 20-30%)仅作为降级选项 **警示信号:** 日志看到 `audio_output task: queue full` 或 Opus 解码间隔超过 25ms。 ### Pitfall 4: PSRAM 分配失败 **现象:** 启动时 `HumanFaceDetect` 构造函数返回 NULL 或 abort,因为 8MB PSRAM 已经被 LVGL 帧缓冲、JPEG 编码器、frame_ 占满。 **根因:** esp-dl 默认用 `MALLOC_CAP_SPIRAM` 分配模型权重(~200KB)和中间 tensor buffer(~300KB),总共约 500KB PSRAM。 **缓解:** - 构造时检查:`detector = new(std::nothrow) HumanFaceDetect(); if (!detector) { ESP_LOGE(...); return; }` - PSRAM 预算在当前项目中:LVGL ~150KB + 摄像头帧(QVGA YUYV=150KB) + Opus buffer ~50KB + 余量 ~7.5MB,**足够**。 - 触发 OOM 时,降级为 `ESPDET_PICO_224_224_FACE`(单阶段模型,内存更少但 122ms 延迟,FPS 降至 8)。 ### Pitfall 5: RP2040 端 UART1 已有消费者 **现象:** 新增 `face:x,y` 协议后 RP2040 端解析错乱。 **根因:** RP2040 `main.py:124` 的 `external.esp_read()` 已经消费 UART1,返回 `commands` 列表。任何新协议必须进入同一分发链。 **缓解:** 见上文 Pattern 3,将 `face:` 前缀的解析插入 `for data in incoming_commands` 循环的最前面。 ### Pitfall 6: UART1 波特率不足导致丢包 **现象:** 10 FPS 持续发 `face:50,-30\n`(约 14 bytes/帧)理论带宽 = 140 bytes/s,115200 bps 绰绰有余(~11 kB/s)。但如果同时夹杂 `speaking`、`listening` 等状态字符串高频切换,可能在同一 tick 内发送多条。 **缓解:** 保持 115200,不升高;`uart_write_bytes` 同步阻塞即可。 ### Pitfall 7: RP2040 的 facetrack() 数据格式不匹配 **现象:** Grove Vision AI V2 的输出是 `boxes:[224,224,100,100,0]`,即 `[x_center, y_center, w, h, score]`,除以 224 后减 `pixel_centre=112` 得到 offset;但 esp-dl 输出的是 bbox 左上+右下坐标 `[x1, y1, x2, y2]`,单位为图像原始像素(如 320×240)。 **缓解:** ESP32 端在发送前就完成"归一化到 224×224"的映射: ```cpp int cx = (r.box[0] + r.box[2]) / 2; int cy = (r.box[1] + r.box[3]) / 2; int x_offset = cx * 224 / frame.width - 112; // -112 ~ +112 int y_offset = cy * 224 / frame.height - 112; ``` 这样 RP2040 端无须修改 `pixel_centre=112`、`deadzone=20`、`x_adj_factor=10` 等参数,即可与原 Grove 协议行为一致。 --- ## Code Examples ### 官方 human_face_detect example(来源:esp-dl master) ```cpp // 来源: https://github.com/espressif/esp-dl/blob/master/examples/human_face_detect/main/app_main.cpp #include "dl_image_jpeg.hpp" #include "human_face_detect.hpp" extern "C" void app_main(void) { dl::image::jpeg_img_t jpeg_img = {.data = (void *)human_face_jpg_start, .data_len = (size_t)(human_face_jpg_end - human_face_jpg_start)}; auto img = dl::image::sw_decode_jpeg(jpeg_img, dl::image::DL_IMAGE_PIX_TYPE_RGB888); HumanFaceDetect *detect = new HumanFaceDetect(); auto &detect_results = detect->run(img); for (const auto &res : detect_results) { ESP_LOGI(TAG, "[score: %f, x1: %d, y1: %d, x2: %d, y2: %d]", res.score, res.box[0], res.box[1], res.box[2], res.box[3]); } delete detect; heap_caps_free(img.data); } ``` ### 本项目适配版(建议模板) ```cpp // face_tracker.cc(新增) #include "face_tracker.h" #include "board.h" #include "esp32_camera.h" #include "uart_component.h" #include "human_face_detect.hpp" #include "dl_image_define.hpp" #include #include #include #define TAG "FaceTracker" static TaskHandle_t s_task = nullptr; static volatile bool s_stop = false; static void face_tracker_task(void* arg) { vTaskDelay(pdMS_TO_TICKS(500)); // 等待摄像头 ISP 预热 HumanFaceDetect* detector = new(std::nothrow) HumanFaceDetect(); if (!detector) { ESP_LOGE(TAG, "HumanFaceDetect init failed (OOM?)"); vTaskDelete(NULL); return; } ESP_LOGI(TAG, "人脸检测任务启动,采样间隔 100ms"); const TickType_t period = pdMS_TO_TICKS(100); // 10 FPS TickType_t last_wake = xTaskGetTickCount(); int no_face_counter = 0; while (!s_stop) { vTaskDelayUntil(&last_wake, period); auto cam = dynamic_cast(Board::GetInstance().GetCamera()); if (!cam) continue; Esp32Camera::FrameRef frame; // 新 API if (!cam->CaptureForDetection(&frame)) continue; dl::image::img_t img = { .data = (void*)frame.data, .width = frame.width, .height = frame.height, .pix_type = dl::image::DL_IMAGE_PIX_TYPE_YUYV, }; auto& results = detector->run(img); if (results.empty()) { no_face_counter++; continue; } no_face_counter = 0; const auto& r = results.front(); int cx = (r.box[0] + r.box[2]) / 2; int cy = (r.box[1] + r.box[3]) / 2; int x_offset = cx * 224 / frame.width - 112; int y_offset = cy * 224 / frame.height - 112; char buf[32]; snprintf(buf, sizeof(buf), "face:%d,%d", x_offset, y_offset); uart_send_string(buf); ESP_LOGD(TAG, "face detected: score=%.2f, offset=(%d,%d)", r.score, x_offset, y_offset); } delete detector; vTaskDelete(NULL); } void face_tracker_start(void) { if (s_task) return; s_stop = false; xTaskCreatePinnedToCore(face_tracker_task, "face_track", 8 * 1024, NULL, 2, &s_task, 0); } void face_tracker_stop(void) { if (!s_task) return; s_stop = true; // task 自己 vTaskDelete,不需要外部 join s_task = nullptr; } ``` ### 扩展 `Esp32Camera` 新增 `CaptureForDetection` ```cpp // esp32_camera.h 新增 struct FrameRef { const uint8_t* data; size_t len; uint16_t width, height; v4l2_pix_fmt_t format; }; virtual bool CaptureForDetection(FrameRef* out); // esp32_camera.cc 新增实现(简化版) bool Esp32Camera::CaptureForDetection(FrameRef* out) { if (!streaming_on_ || video_fd_ < 0) return false; std::lock_guard lock(capture_mutex_); // 新增成员 struct v4l2_buffer buf = {}; buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; buf.memory = V4L2_MEMORY_MMAP; if (ioctl(video_fd_, VIDIOC_DQBUF, &buf) != 0) return false; // 不拷贝、不旋转、不显示——直接给 esp-dl 原始 YUYV out->data = (const uint8_t*)mmap_buffers_[buf.index].start; out->len = buf.bytesused; out->width = frame_.width; out->height = frame_.height; out->format = sensor_format_; // 警告:返回给 caller 后必须立即用完,因为 VIDIOC_QBUF 后缓冲会被 ISP 覆写 // caller 在 run() 完成后立即 ReleaseDetectionFrame() 归还 return true; } // (需要配套 ReleaseDetectionFrame 做 VIDIOC_QBUF,此处省略) ``` **注意:** 上述代码是骨架示意,实际实现需要仔细处理 V4L2 缓冲区生命周期——可能更简单的做法是复用 `Capture()` 的 `frame_.data`(已 memcpy 到 PSRAM),face_tracker 直接访问 `frame_`(需把 `frame_` 改为 protected 或新增 getter)。 ### UART 扩展(`uart_component.h`) ```cpp // 新增函数,维持现有 uart_send_string 不变 void uart_send_face(int x_offset, int y_offset); // uart_component.cc 实现 void uart_send_face(int x_offset, int y_offset) { char buf[24]; int n = snprintf(buf, sizeof(buf), "face:%d,%d", x_offset, y_offset); if (n > 0 && n < (int)sizeof(buf)) { uart_write_bytes(UART_PORT_NUM, buf, n); uart_write_bytes(UART_PORT_NUM, "\r\n", 2); } } ``` ### RP2040 端扩展(`coms.py`) ```python # 在 Comms 类新增方法 def parse_face(self, line): """解析 ESP32 发来的 'face:X,Y' 坐标协议 Args: line: 解码后的单行字符串(不含 \\r\\n) Returns: (x_offset, y_offset) tuple,或 None 如果格式不匹配 """ if not line.startswith('face:'): return None try: x_str, y_str = line[5:].split(',', 1) return (int(x_str), int(y_str)) except (ValueError, IndexError): return None ``` ### RP2040 端扩展(`main.py` L123-131) ```python # 原代码(L123-131) incoming_commands = external.esp_read() for data in incoming_commands: if data in animation.action_map: animation.action_map[data]() elif data in animation.state_map: animation.new_state_flag = True animation.current_state = data # 改造后:优先解析 face: 协议 incoming_commands = external.esp_read() for data in incoming_commands: face_offset = external.parse_face(data) if face_offset is not None: # 新增:ESP32 人脸坐标接管 grove_active animation.grove_active = True animation.grove_last_seen = time.ticks_ms() external.last_face_offset = face_offset # 新增成员 continue if data in animation.action_map: animation.action_map[data]() elif data in animation.state_map: animation.new_state_flag = True animation.current_state = data ``` 然后改造 `facetrack()`: ```python def facetrack(): global yaw_countdown, yaw_target # 优先使用 ESP32 来源;无则 fallback 到 Grove offset = getattr(external, 'last_face_offset', None) if offset is None: offset = external.grove_read() # 清除 ESP32 offset(单次使用,避免同一坐标重复驱动舵机) external.last_face_offset = None # ... 后续保持现有逻辑 ``` --- ## State of the Art | 老做法 | 当前做法 | 改变时点 | 影响 | |--------|----------|----------|------| | esp-who `face_detect()` + `dl_matrix3du_t` | esp-dl `HumanFaceDetect::run(img_t&)` | esp-dl v3.0.0 (2023-12) | API 完全不兼容,老 esp-who 代码无法直接移植 | | `esp32-camera` + `esp_camera_fb_get()` | `esp_video` + V4L2 `ioctl(VIDIOC_DQBUF)` | ESP-IDF 5.2+ | 本项目已在用 esp_video | | 硬编码模型到 rodata | Kconfig 可选 3 种 location(rodata/partition/sdcard) | human_face_detect v0.3.0 | 部署灵活性 | | MSR01 + MNP01(v2) | MSR_S8_V1 + MNP_S8_V1(int8 量化) | esp-dl v3.1.0 (2025-01) | 模型更小、推理更快 | **已弃用的做法:** - **CONFIG_BT_BLUFI_ENABLE**:不相关,但注意 esp-dl 与 WiFi 并存无冲突 - **旧的 `dl_matrix3du_t`**:v3.0.0 起被 `dl::image::img_t` 替代 --- ## Assumptions Log | # | 假设 | 所在章节 | 风险 | |---|------|---------|------| | A1 | `Esp32Camera` 的 YUYV 格式与 esp-dl 的 `DL_IMAGE_PIX_TYPE_YUYV` 字节序一致 | Code Examples | 若字节序相反(YUYV vs UYVY)颜色通道错乱,推理精度下降(bbox 仍可用但 score 低)。**缓解**:第一轮测试打印 score,若 < 0.5 则切换到 UYVY 或转 RGB565 | | A2 | ESP32-S3 QVGA 推理实测 FPS ≥ 10 | 摘要 | 实验室环境可能 8-12 FPS;若低于 5 FPS 需降级到 QQVGA 160×120 | | A3 | `uart_send_string` 不会因同时被多任务调用产生乱码 | Pattern 3 | UART driver 有内部锁,但 `uart_write_bytes` 本身不是 mutex-protected。**缓解**:新增 `uart_mutex_`,`uart_send_string` 和 `uart_send_face` 都加锁 | | A4 | 新增任务的 PSRAM 用量 ~500KB 不会导致其他组件 OOM | Pitfall 4 | 当前项目 PSRAM 总量 8MB,预估占用 2-3MB(LVGL+帧+AEC 等),余量充足但未精确测量 | | A5 | RP2040 `coms.py` 可以修改(不是只读代码库) | RP2040 改造 | GOAL.md 明确 RP2040 目录可改 | | A6 | 用户接受坐标发送频率固定为 10 FPS | 任务调度 | 需用户决策,见 Open Questions | **如果这张表为空:** 所有关键声明都经过验证或引用 — 无需用户确认。 --- ## Open Questions 1. **模型部署位置:rodata vs partition?** - 我们知道:人 face detect v0.4.1 支持三种位置;模型总大小 ~190KB(msr=60KB + mnp=127KB)。 - 不确定:是否愿意让 `xiaozhi.bin` 增大 ~200KB(从 2.59MB 到 ~2.8MB,仍远小于 3.9MB ota 分区)?还是拆到独立 partition 以方便 OTA 独立更新? - 建议:**选 rodata(方案 A)**,简化 OTA 流程。ota_0/ota_1 仍有 ~1.1MB 余量。 2. **坐标发送频率:5 FPS vs 10 FPS vs 动态?** - 我们知道:推理耗时 ~38ms,理论上限 20 FPS;RP2040 舵机响应时间 ~50ms;UART 带宽充足。 - 不确定:用户希望人脸追踪有多"跟手"?过高帧率会加剧 Core 0 负载,可能影响音频。 - 建议:**默认 10 FPS,通过 Kconfig 可调为 5/10/15**。 3. **检测范围:始终开启 vs 仅 speaking/listening 时开启?** - 我们知道:GOAL.md 说"不破坏现有功能"、"可维护性:支持无 Grove 和有 Grove 两种模式自动切换"。 - 不确定:idle 状态(未激活)时是否也要持续人脸检测?这会增加常态功耗。 - 建议:**始终开启**,简化状态机;如果 OOM/性能问题再加 Kconfig 关闭 idle 时检测。 4. **`face:x,y` 协议是否需要 score 字段?** - 当前设计:只发坐标,不发置信度。 - 替代:`face:x,y,score\n`,RP2040 端可根据 score 判断是否响应。 - 建议:**先不发 score**,减少解析复杂度;如果 false positive 多再加。 5. **Kconfig 开关默认值:`y` vs `n`?** - 建议:`CONFIG_XIAOZHI_ENABLE_FACE_TRACKING default y`,与 Phase 目标"人脸追踪是默认功能"匹配。 - 保留关闭选项以便调试、基线性能对比。 6. **无脸超时策略:3 秒仍由 RP2040 处理 vs ESP32 显式发信号?** - 当前设计:ESP32 停止发 `face:`,RP2040 自动 `grove_active=False`(复用现有 3s 超时)。 - 备选:ESP32 主动发 `face:none\n`,RP2040 立即切回随机动画。 - 建议:**保持超时机制**,避免新协议;但打 TODO 如果感觉延迟明显再加显式信号。 --- ## Environment Availability | 依赖 | 谁需要 | 可用 | 版本 | 回退 | |------|--------|------|------|------| | ESP-IDF | 构建系统 | ✓(dependencies.lock 锁定) | 5.4.2 | — | | PSRAM(8MB OCT) | LVGL、帧缓冲、esp-dl | ✓ | N16R8 | 无(N16R0/N4R2 无法运行) | | OV3660 + 飞线 | 摄像头采集 | ✓(已完成 3 根) | — | 若飞线虚焊需硬件重做 | | UART1(GPIO17/18) | 发送坐标 | ✓ | — | — | | RP2040 固件可修改 | 接收协议 | ✓(独立代码库) | MicroPython | — | | esp-dl + human_face_detect | 推理 | ✗(需 `idf.py add-dependency`) | 待安装 3.2.0 + 0.4.1 | 无(Phase 核心) | | OV3660 xiaozhi 兼容性 | Camera stack | ⚠️ | — | issue #1588 未 resolve;若复现需独立修复 | **缺失但无回退:** - esp-dl / human_face_detect — Phase 开工第一步就安装。 **警示项:** - OV3660 + xiaozhi 的 issue #1588 — 需先 sanity test `cam->Capture()` 能工作;若失败先修驱动配置。 --- ## Validation Architecture ### 测试框架 | 属性 | 值 | |------|----| | 框架 | 无正式单元测试框架(xiaozhi 项目未使用 Unity) | | 配置文件 | 无 | | 快速运行命令 | 无—靠日志 + 手动测试 | | 完整套件命令 | `idf.py build && idf.py flash monitor` | **说明:** xiaozhi-esp32 项目本身没有 Unity/Catch2 测试基础设施,遵循手动 + 日志观测的验收方式。Phase 要求里规定了明确的 FPS / 延迟 / 功能正确性标准,用以下方式验收: ### Phase 需求 → 测试映射 | 需求 | 行为 | 测试类型 | 自动化命令 | 文件存在? | |------|------|---------|-----------|----------| | REQ-01 | QVGA 帧率 ≥ 5 FPS | 日志观测 | `grep "face detected" monitor.log \| tail -20`(估算间隔) | N/A | | REQ-02 | 人脸检测延迟 ≤ 200ms | 代码内置 timestamp | 在 face_tracker_task 里加 `esp_timer_get_time()` 前后对比,打 ESP_LOGI | 需新增埋点 | | REQ-03 | 坐标传输延迟 ≤ 50ms | UART 波特率推算 | 115200 bps @ 14 bytes ≈ 1ms,主要看 RP2040 处理速度 | RP2040 端埋点 `time.ticks_diff()` | | REQ-04 | 检测到脸时 RP2040 眼球追踪 | 手动观察 | 人在摄像头前移动,观察眼球 | 无 | | REQ-05 | 无脸 3 秒后回退随机动画 | 手动观察 | 遮挡摄像头 3s,观察眼球是否切换 | 无 | | REQ-06 | 语音对话不卡顿 | 手动观察 + 日志 | 启动 WebSocket 对话,开启 face_tracker,对比开关时的 Opus 日志 | 无 | | REQ-07 | 唤醒词仍生效 | 手动观察 | 说"你好小智",检查是否进入 listening | 无 | | REQ-08 | UART 现有状态字符串仍工作 | 日志 + 手动 | 触发 `speaking`/`listening`,观察 RP2040 反应 | 无 | ### 采样策略 - **每次代码修改:** 本地 `idf.py flash monitor`,人工观察 FPS 日志和舵机动作 - **每个 wave 合并:** 录制 30s 视频,包含:(a) 人脸追踪 (b) 语音对话 (c) 两者同时 - **Phase gate:** 在 CogNog V1.0 硬件上通过所有 8 个需求人工验收 ### Wave 0 Gaps 由于项目没有自动化测试框架,Wave 0 无需新增测试文件,但建议: - [ ] `main/face_tracker.cc` 内置性能埋点(推理耗时、FPS、无脸计数) - [ ] `main/uart_component.cc` 内置 face 协议发送计数器(ESP_LOGI 每 10 秒一次) - [ ] RP2040 `main.py` 在 parse_face 成功时打印 `print(f"ESP32 face: {offset}")` --- ## Sources ### Primary (HIGH confidence) - [esp-dl v3.2.0 release notes (2025-10-23)](https://github.com/espressif/esp-dl/releases) — 确认最新版本、ESP-IDF 5.3+ 依赖、双核调度特性 - [human_face_detect v0.4.1 on ESP Component Registry](https://components.espressif.com/components/espressif/human_face_detect) — 确认模型大小、推理延迟、API 签名 - [esp-dl v3.2.0 dependencies](https://components.espressif.com/components/espressif/esp-dl/versions/3.2.0/dependencies) — 确认 4 个依赖项和版本约束 - [human_face_detect README](https://github.com/espressif/esp-dl/tree/master/models/human_face_detect) — 确认 ESP32-S3 上 msr_s8_v1_s3 耗时 32.4ms, mnp_s8_v1_s3 耗时 5.6ms - [human_face_detect example code (master)](https://github.com/espressif/esp-dl/blob/master/examples/human_face_detect/main/app_main.cpp) — 完整 run() 使用示例 - [dl_image_define.hpp](https://github.com/espressif/esp-dl/blob/master/esp-dl/vision/image/dl_image_define.hpp) — 确认支持 YUYV / RGB565LE / RGB888 等输入格式 - [dl_image_process.hpp](https://github.com/espressif/esp-dl/blob/master/esp-dl/vision/image/dl_image_process.hpp) — 确认 ImageTransformer 自动处理 resize/normalize - [dl_image_color.hpp](https://github.com/espressif/esp-dl/blob/master/esp-dl/vision/image/dl_image_color.hpp) — 确认 YUV→RGB565/888 转换支持 - [human_face_detect partitions2.csv](https://github.com/espressif/esp-dl/blob/master/models/human_face_detect/partitions2.csv) — 确认分区名 `human_face_det`, 建议大小 200KB - 本项目源码:`main/boards/bread-compact-wifi-s3cam/`、`main/boards/common/esp32_camera.cc`、`main/uart_component.{h,cc}`、`main/application.cc`、`dependencies.lock` — 所有集成点已读 - RP2040 源码:`/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/{main,coms,animation}.py` — grove_active 机制、state_map/action_map、facetrack() 已读 ### Secondary (MEDIUM confidence) - [OV3660 FPS @ QVGA benchmark](https://github.com/espressif/esp32-camera/issues/232) — 社区报告 18-20 FPS @ 20MHz XCLK(不同库但硬件相同,可作参考) - [ESP32-S3 face detection community reports](https://www.espressif.com/en/products/devkits/esp-eye/overview) — 官方声明 MSR01 可达 10-15 FPS - [xiaozhi issue #1588 OV3660 crash](https://github.com/78/xiaozhi-esp32/issues/1588) — 已知兼容性问题 ### Tertiary (LOW confidence,需实机验证) - Core 0 vs Core 1 对音频的影响 — 基于 xiaozhi 项目已有经验法则(audio_input priority=8 Core 0, LVGL Core 1),无官方 esp-dl 文档明确要求 - 实际 PSRAM 占用 500KB — 估算值,需要运行 `heap_caps_print_heap_info(MALLOC_CAP_SPIRAM)` 确认 --- ## Metadata **置信度分解:** - Standard Stack: **HIGH** — 所有版本号、API 签名、模型大小均通过官方源验证 - Architecture: **HIGH** — 现有代码已读,集成点清晰 - Pitfalls: **MEDIUM-HIGH** — Pitfall 1-2 有 GitHub issue 背书;Pitfall 3-4 基于 esp-dl 文档 + 通用嵌入式经验 - Performance numbers: **MEDIUM** — 官方 latency 数字 HIGH;FPS 上限是推算值,实测可能有 ±30% 偏差 **Research date:** 2026-04-17 **Valid until:** 2026-05-17(esp-dl 为活跃项目,30 天内可能发布 v3.3.0,届时需回查 release notes) --- ## 集成点清单(供 Planner 使用) ### 新增文件 1. `main/face_tracker.h` + `main/face_tracker.cc` — 新增,任务封装 2. `main/idf_component.yml` — 追加 esp-dl + human_face_detect 依赖 3. `main/CMakeLists.txt` — 在 `set(SOURCES ...)` 加 `"face_tracker.cc"` ### 需修改文件 | 文件 | 行号 | 修改内容 | |------|------|---------| | `main/uart_component.h` | 结尾 | 新增 `void uart_send_face(int x, int y);` | | `main/uart_component.cc` | 结尾 | 新增 `uart_send_face` 实现 | | `main/boards/common/esp32_camera.h` | L22 class 内 | 新增 `CaptureForDetection()` + `FrameRef` struct | | `main/boards/common/esp32_camera.cc` | 类外实现 | 新增 `CaptureForDetection()` 实现(或将 `frame_` 改为 protected 提供 getter) | | `main/application.cc` | L358 `Start()` 结尾 | 调用 `face_tracker_start()`(在 StartNetwork 之后) | | `main/application.cc` | L704/714/726 | **不需要修改**,现有 `uart_send_string("idle"/"listening"/"speaking")` 维持原状 | | `main/Kconfig.projbuild` | Camera Configuration menu 结尾 | 新增 `config XIAOZHI_ENABLE_FACE_TRACKING`(bool, default y, depends on 有摄像头的板型) | ### RP2040 端修改 | 文件 | 行号 | 修改内容 | |------|------|---------| | `/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/coms.py` | 新增方法 | `parse_face(line)` 解析 `face:x,y` 字符串 | | `/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/coms.py` | `__init__` | 新增 `self.last_face_offset = None` | | `/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/main.py` | L123-131 | 在 for 循环里优先判断 parse_face | | `/Users/rdzleo/Desktop/CogletESP-CogletESP/RP2040/main.py` | L38-54 `facetrack()` | 优先用 `external.last_face_offset`,fallback 到 `external.grove_read()` | ### 无需修改的组件 - `main/main.cc` — `uart_init_component()` 调用点不变 - `main/boards/bread-compact-wifi-s3cam/config.h` — GPIO17/18 已在 uart_component.h 配置,无冲突 - `partitions/v2/16m.csv` — 选方案 A(rodata)无需改动 - `sdkconfig.defaults.esp32s3` — 无需改动(PSRAM 已启用) - 现有 `display/display.cc` L42 `uart_send_string(emotion)` — 保持 - LVGL / 音频 / WiFi / WebSocket 等所有模块 — 保持 --- ## 任务架构总结 | 任务名 | Core | 优先级 | 栈 | 说明 | |--------|------|-------|-----|------| | `main_event_loop` | 任意 | 3 | 8KB | 现有,不变 | | `audio_input` | 0 | 8 | 6KB | 现有,不变(最高优先级) | | `audio_output` | 任意 | 4 | 4KB | 现有,不变 | | LVGL port task | 1 | 2 | 8KB(默认) | 现有 `lcd_display.cc:131` `task_affinity=1` | | **`face_track`**(新增) | **0** | **2** | **8KB** | **10 FPS 限频,与 LVGL 分离 Core** | **Core 分配理由:** - Core 0 已跑:main_event_loop(P=3)、audio_input(P=8) - Core 1 已跑:LVGL port(P=2) - face_track 放 Core 0 P=2:不会抢占 audio_input(高优先级),不与 LVGL 在同一 Core 争抢 - esp-dl 的 Conv2D 支持双核调度,会自动把计算分到 Core 1,理论上可用两核