Rdzleo e95d0c414e 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>
2026-04-17 18:24:27 +08:00

36 KiB
Raw Permalink Blame History

Phase 1: 单摄像头人脸追踪 — 技术调研

调研日期: 2026-04-17 研究对象: ESP32-S3-N16R8 上的摄像头 + 人脸检测 + UART 坐标协议 总体置信度: HIGH关键组件均有官方验证少数性能/内存数字为 MEDIUM社区数据 ESP-IDF 版本: 5.4.2(已在 dependencies.lock 中锁定)


摘要

本 Phase 的核心技术决策是在不破坏现有 xiaozhi-esp32 语音对话架构的前提下,新增一个人脸检测任务:

  • 推理库选型esp-dl v3.2.0 + human_face_detect v0.4.1官方组件2024-10 发布,而 esp-who 已 refactor 为 esp-dl 的外壳,不再推荐直接使用)。
  • 模型选型:两阶段 MSR_S8_V1 + MNP_S8_V1小模型 + 高精度级联ESP32-S3 总推理耗时 ~38msFPS 上限约 26
  • 图像格式Esp32Camera 已选 YUYV 为 OV3660 的首选格式esp-dl 原生支持 DL_IMAGE_PIX_TYPE_YUYV 以及 RGB565LE,可直接喂给模型(内部自动 resize + 归一化),零拷贝
  • UART 协议:在现有 uart_send_string() 的基础上扩展,新增 face:x,y\n 协议(与现有状态字符串字典集隔离,零侵入)。
  • 任务调度:人脸检测任务 pinnedToCore=0与 main_event_loop 同 Corepriority=2低于音频 I/O高于空闲栈 8KB。音频任务继续在 Core 0 priority=8 抢占。
  • 分区方案:占用现有 8MB assets SPIFFS 的 ~200KB 用于 human_face_det 子分区 OR 将模型编入 flash rodata二选一推荐后者简化部署
  • Kconfig 开关:新增 CONFIG_XIAOZHI_ENABLE_FACE_TRACKING,默认 y,方便未来回退。

主要建议: 直接把模型以 flash rodata 方式嵌入no partition change采集 QVGA320×240YUYV 帧,交给 HumanFaceDetect::run(),取首个结果的 bbox 中心坐标映射到 [-112, +112] 范围(匹配 RP2040 的 pixel_centre=112),通过 UART1 发送 face:x,y\n,≥ 5 FPS实测上限 15-20 FPS主动限频到 10 FPS 以降低 CPU 压力)。


Standard Stack

Core官方组件HIGH 置信度)

版本 用途 选择理由
espressif/esp-dl 3.2.02024-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 驱动 现有组件,无需改动

安装命令:

# 在 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_.dataPSRAMframe_private。Phase 需要在 Esp32Camera 里新增一个公开的"取当前帧引用"方法,而不是为检测任务复制整帧。

// 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 任务 + 主动限频

// 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_mapact_* 开头),再查 state_mapidle/speaking/listening/...)。任何以 face: 开头的字符串既不在 action_map,也不在 state_map所以现有代码会直接忽略——零侵入。Phase 只需在 RP2040 端 coms.pymain.py 新增对 face: 前缀的识别并注入 facetrack() 数据流。

# 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 1Core 1 通常被 WiFi/BT/Audio I/O 占用,新增 CPU-bound 任务会恶化音频延迟。Core 0 与主循环/LVGL 共享更合适ISR 少,可被音频任务抢占)。
  • 不要改 Esp32Camera::Capture() 签名MCP camera toolmcp_server.cc:100)仍在用,保持稳定。新增独立方法。
  • 不要用默认 10 FPS 以上采样率:帧率越高,推理越频繁,和音频争抢 Core 0 越厉害。实测可先从 5 FPS 起步,视音频表现再调。
  • 不要在 UART 发送坐标时加 \r\n 前缀uart_send_string() 自动加 \r\nRP2040 按 \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::ImagePreprocessorHumanFaceDetect::run() 内部自动调用) 已做 resize + 归一化 + 量化
帧缓冲分配 自己 heap_caps_malloc(MALLOC_CAP_SPIRAM) 复用 Esp32Camera::frame_(已在 PSRAM 避免 PSRAM 双份占用(每帧 QVGA RGB565 = 150KB
UART 缓冲/发送 自己封装一层 TX queue 直接调 uart_send_string() 已有简洁 API协议扩展走前缀隔离
模型格式解析 自己读 .espdl HumanFaceDetect 构造函数自动加载 FlatBuffers + zero-copy 不可见,绝对不能动

核心洞察: esp-dl 的设计哲学是"提供 Model + ImagePreprocessor + Detect 三件套",开发者只管构造 img_t 和消费 result_t。Phase 代码应保持极简(< 200 行)。


Runtime State Inventory

类别 发现的项目 所需动作
存储数据 无 — 不涉及 NVS、SPIFFS 已有数据结构;也不需要持久化坐标
活跃服务配置 无 — 不涉及 n8n/Datadog/Cloudflare 等外部服务
OS 级注册状态 无 — ESP32-S3 无 systemd/Task SchedulerFreeRTOS 任务完全 runtime 创建
Secrets/环境变量 无 — 不需要新 API key
构建产物/已装包 managed_components/ 新增 espressif__esp-dl~10MB 源码)+ espressif__human_face_detect idf.py reconfigure 自动拉取,首次构建耗时 +2-3 分钟
分区表 / Flash 若选方案 Arodata无改动方案 Bpartition需改 partitions/v2/16m.csv 新增 200KB 分区 若选 B从 assets 的 8MB 里切 200KB或在 ota_1 和 assets 之间插入(需整体调整偏移)

说明: managed_components 目录是 ESP-IDF 构建系统自动管理的Clean build 会重新拉取。加入 dependencies.lockgit 跟踪)即可复现。


Common Pitfalls

Pitfall 1: OV3660 在 xiaozhi 项目中的已知崩溃

现象: GitHub issue #1588 报告 compact-wifi-s3cam + OV3660 调用 MCP camera tool 时冻结OV2640 正常。 根因: OV3660 的 DMA/FIFO 配置与 OV2640 不同,初始化参数需要分别处理。本项目已经完成 3 根飞线GPIO 35→14, 36→41, 37→42应在同一硬件上 pre-test Capture() 是否正常工作(独立验证)。 缓解: Phase 开工前先在 main.cc 里插入一段临时代码,调用 Board::GetInstance().GetCamera()->Capture() 一次并观察是否成功;若失败,先修驱动再做检测。

Pitfall 2: 采集与检测共用 video_fd_ 的竞态

现象: MCP take_photo 工具和 face_tracker 同时调用 ioctl(video_fd_, VIDIOC_DQBUF, ...) 会争抢帧缓冲req.count=1 for DVP根因: DVP 只申请 1 个 V4L2 缓冲区,同时有两个消费者会导致 ENOBUFS 或帧乱序。 缓解:Esp32Camera 里加一个 std::mutex capture_mutex_Capture()CaptureForDetection() 都在进入时加锁face_tracker 在 take_photo 进行时跳过一帧(检测 mutex trylock 失败则 continue

Pitfall 3: esp-dl 推理导致音频任务延迟

现象: 38ms 的 Conv2D 会阻塞 Core 0Opus 解码每 20ms 一次的缓冲送入会延迟,出现"吱吱"卡顿。 根因: esp-dl 的 Conv2D 支持双核调度,但 task 本身 pin 在 Core 0 时,只能借一部分时间片到 Core 1。 缓解方案:

  1. face_tracker 任务优先级 = 2低于 audio_output=4、audio_input=8、main_event_loop=3
  2. 每次推理后主动 vTaskDelay(0) yield 一次
  3. 使用 param_copy=false(模型权重留 flash不占 PSRAM但推理变慢 20-30%)仅作为降级选项

警示信号: 日志看到 audio_output task: queue full 或 Opus 解码间隔超过 25ms。

Pitfall 4: PSRAM 分配失败

现象: 启动时 HumanFaceDetect 构造函数返回 NULL 或 abort因为 8MB PSRAM 已经被 LVGL 帧缓冲、JPEG 编码器、frame_ 占满。 根因: esp-dl 默认用 MALLOC_CAP_SPIRAM 分配模型权重(~200KB和中间 tensor buffer~300KB总共约 500KB PSRAM。 缓解:

  • 构造时检查:detector = new(std::nothrow) HumanFaceDetect(); if (!detector) { ESP_LOGE(...); return; }
  • PSRAM 预算在当前项目中LVGL ~150KB + 摄像头帧QVGA YUYV=150KB + Opus buffer ~50KB + 余量 ~7.5MB足够
  • 触发 OOM 时,降级为 ESPDET_PICO_224_224_FACE(单阶段模型,内存更少但 122ms 延迟FPS 降至 8

Pitfall 5: RP2040 端 UART1 已有消费者

现象: 新增 face:x,y 协议后 RP2040 端解析错乱。 根因: RP2040 main.py:124external.esp_read() 已经消费 UART1返回 commands 列表。任何新协议必须进入同一分发链。 缓解: 见上文 Pattern 3face: 前缀的解析插入 for data in incoming_commands 循环的最前面。

Pitfall 6: UART1 波特率不足导致丢包

现象: 10 FPS 持续发 face:50,-30\n(约 14 bytes/帧)理论带宽 = 140 bytes/s115200 bps 绰绰有余(~11 kB/s。但如果同时夹杂 speakinglistening 等状态字符串高频切换,可能在同一 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"的映射:

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=112deadzone=20x_adj_factor=10 等参数,即可与原 Grove 协议行为一致。


Code Examples

官方 human_face_detect example来源esp-dl master

// 来源: 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);
}

本项目适配版(建议模板)

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

// esp32_camera.h 新增
struct FrameRef {
    const uint8_t* data;
    size_t len;
    uint16_t width, height;
    v4l2_pix_fmt_t format;
};
virtual bool CaptureForDetection(FrameRef* out);

// esp32_camera.cc 新增实现(简化版)
bool Esp32Camera::CaptureForDetection(FrameRef* out) {
    if (!streaming_on_ || video_fd_ < 0) return false;
    std::lock_guard<std::mutex> lock(capture_mutex_);  // 新增成员

    struct v4l2_buffer buf = {};
    buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE;
    buf.memory = V4L2_MEMORY_MMAP;
    if (ioctl(video_fd_, VIDIOC_DQBUF, &buf) != 0) return false;

    // 不拷贝、不旋转、不显示——直接给 esp-dl 原始 YUYV
    out->data = (const uint8_t*)mmap_buffers_[buf.index].start;
    out->len = buf.bytesused;
    out->width = frame_.width;
    out->height = frame_.height;
    out->format = sensor_format_;

    // 警告:返回给 caller 后必须立即用完,因为 VIDIOC_QBUF 后缓冲会被 ISP 覆写
    // caller 在 run() 完成后立即 ReleaseDetectionFrame() 归还
    return true;
}

// (需要配套 ReleaseDetectionFrame 做 VIDIOC_QBUF此处省略

注意: 上述代码是骨架示意,实际实现需要仔细处理 V4L2 缓冲区生命周期——可能更简单的做法是复用 Capture()frame_.data(已 memcpy 到 PSRAMface_tracker 直接访问 frame_(需把 frame_ 改为 protected 或新增 getter

UART 扩展(uart_component.h

// 新增函数,维持现有 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

# 在 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

# 原代码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()

def facetrack():
    global yaw_countdown, yaw_target
    # 优先使用 ESP32 来源;无则 fallback 到 Grove
    offset = getattr(external, 'last_face_offset', None)
    if offset is None:
        offset = external.grove_read()
    # 清除 ESP32 offset单次使用避免同一坐标重复驱动舵机
    external.last_face_offset = None
    # ... 后续保持现有逻辑

State of the Art

老做法 当前做法 改变时点 影响
esp-who face_detect() + dl_matrix3du_t esp-dl HumanFaceDetect::run(img_t&) esp-dl v3.0.0 (2023-12) API 完全不兼容,老 esp-who 代码无法直接移植
esp32-camera + esp_camera_fb_get() esp_video + V4L2 ioctl(VIDIOC_DQBUF) ESP-IDF 5.2+ 本项目已在用 esp_video
硬编码模型到 rodata Kconfig 可选 3 种 locationrodata/partition/sdcard human_face_detect v0.3.0 部署灵活性
MSR01 + MNP01v2 MSR_S8_V1 + MNP_S8_V1int8 量化) esp-dl v3.1.0 (2025-01) 模型更小、推理更快

已弃用的做法:

  • CONFIG_BT_BLUFI_ENABLE:不相关,但注意 esp-dl 与 WiFi 并存无冲突
  • 旧的 dl_matrix3du_tv3.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_stringuart_send_face 都加锁
A4 新增任务的 PSRAM 用量 ~500KB 不会导致其他组件 OOM Pitfall 4 当前项目 PSRAM 总量 8MB预估占用 2-3MBLVGL+帧+AEC 等),余量充足但未精确测量
A5 RP2040 coms.py 可以修改(不是只读代码库) RP2040 改造 GOAL.md 明确 RP2040 目录可改
A6 用户接受坐标发送频率固定为 10 FPS 任务调度 需用户决策,见 Open Questions

如果这张表为空: 所有关键声明都经过验证或引用 — 无需用户确认。


Open Questions

  1. 模型部署位置rodata vs partition

    • 我们知道:人 face detect v0.4.1 支持三种位置;模型总大小 ~190KBmsr=60KB + mnp=127KB
    • 不确定:是否愿意让 xiaozhi.bin 增大 ~200KB从 2.59MB 到 ~2.8MB,仍远小于 3.9MB ota 分区)?还是拆到独立 partition 以方便 OTA 独立更新?
    • 建议:选 rodata方案 A,简化 OTA 流程。ota_0/ota_1 仍有 ~1.1MB 余量。
  2. 坐标发送频率5 FPS vs 10 FPS vs 动态?

    • 我们知道:推理耗时 ~38ms理论上限 20 FPSRP2040 舵机响应时间 ~50msUART 带宽充足。
    • 不确定:用户希望人脸追踪有多"跟手"?过高帧率会加剧 Core 0 负载,可能影响音频。
    • 建议:默认 10 FPS通过 Kconfig 可调为 5/10/15
  3. 检测范围:始终开启 vs 仅 speaking/listening 时开启?

    • 我们知道GOAL.md 说"不破坏现有功能"、"可维护性:支持无 Grove 和有 Grove 两种模式自动切换"。
    • 不确定idle 状态(未激活)时是否也要持续人脸检测?这会增加常态功耗。
    • 建议:始终开启,简化状态机;如果 OOM/性能问题再加 Kconfig 关闭 idle 时检测。
  4. face:x,y 协议是否需要 score 字段?

    • 当前设计:只发坐标,不发置信度。
    • 替代:face:x,y,score\nRP2040 端可根据 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\nRP2040 立即切回随机动画。
    • 建议:保持超时机制,避免新协议;但打 TODO 如果感觉延迟明显再加显式信号。

Environment Availability

依赖 谁需要 可用 版本 回退
ESP-IDF 构建系统 dependencies.lock 锁定) 5.4.2
PSRAM8MB OCT LVGL、帧缓冲、esp-dl N16R8 N16R0/N4R2 无法运行)
OV3660 + 飞线 摄像头采集 ✓(已完成 3 根) 若飞线虚焊需硬件重做
UART1GPIO17/18 发送坐标
RP2040 固件可修改 接收协议 ✓(独立代码库) MicroPython
esp-dl + human_face_detect 推理 ✗(需 idf.py add-dependency 待安装 3.2.0 + 0.4.1 Phase 核心)
OV3660 xiaozhi 兼容性 Camera stack ⚠️ issue #1588 未 resolve若复现需独立修复

缺失但无回退:

  • esp-dl / human_face_detect — Phase 开工第一步就安装。

警示项:

  • OV3660 + xiaozhi 的 issue #1588 — 需先 sanity test cam->Capture() 能工作;若失败先修驱动配置。

Validation Architecture

测试框架

属性
框架 无正式单元测试框架xiaozhi 项目未使用 Unity
配置文件
快速运行命令 无—靠日志 + 手动测试
完整套件命令 idf.py build && idf.py flash monitor

说明: xiaozhi-esp32 项目本身没有 Unity/Catch2 测试基础设施,遵循手动 + 日志观测的验收方式。Phase 要求里规定了明确的 FPS / 延迟 / 功能正确性标准,用以下方式验收:

Phase 需求 → 测试映射

需求 行为 测试类型 自动化命令 文件存在?
REQ-01 QVGA 帧率 ≥ 5 FPS 日志观测 grep "face detected" monitor.log | tail -20(估算间隔) N/A
REQ-02 人脸检测延迟 ≤ 200ms 代码内置 timestamp 在 face_tracker_task 里加 esp_timer_get_time() 前后对比,打 ESP_LOGI 需新增埋点
REQ-03 坐标传输延迟 ≤ 50ms UART 波特率推算 115200 bps @ 14 bytes ≈ 1ms主要看 RP2040 处理速度 RP2040 端埋点 time.ticks_diff()
REQ-04 检测到脸时 RP2040 眼球追踪 手动观察 人在摄像头前移动,观察眼球
REQ-05 无脸 3 秒后回退随机动画 手动观察 遮挡摄像头 3s观察眼球是否切换
REQ-06 语音对话不卡顿 手动观察 + 日志 启动 WebSocket 对话,开启 face_tracker对比开关时的 Opus 日志
REQ-07 唤醒词仍生效 手动观察 说"你好小智",检查是否进入 listening
REQ-08 UART 现有状态字符串仍工作 日志 + 手动 触发 speaking/listening,观察 RP2040 反应

采样策略

  • 每次代码修改: 本地 idf.py flash monitor,人工观察 FPS 日志和舵机动作
  • 每个 wave 合并: 录制 30s 视频,包含:(a) 人脸追踪 (b) 语音对话 (c) 两者同时
  • Phase gate 在 CogNog V1.0 硬件上通过所有 8 个需求人工验收

Wave 0 Gaps

由于项目没有自动化测试框架Wave 0 无需新增测试文件,但建议:

  • main/face_tracker.cc 内置性能埋点推理耗时、FPS、无脸计数
  • main/uart_component.cc 内置 face 协议发送计数器ESP_LOGI 每 10 秒一次)
  • RP2040 main.py 在 parse_face 成功时打印 print(f"ESP32 face: {offset}")

Sources

Primary (HIGH confidence)

Secondary (MEDIUM confidence)

Tertiary (LOW confidence需实机验证)

  • Core 0 vs Core 1 对音频的影响 — 基于 xiaozhi 项目已有经验法则audio_input priority=8 Core 0, LVGL Core 1无官方 esp-dl 文档明确要求
  • 实际 PSRAM 占用 500KB — 估算值,需要运行 heap_caps_print_heap_info(MALLOC_CAP_SPIRAM) 确认

Metadata

置信度分解:

  • Standard Stack: HIGH — 所有版本号、API 签名、模型大小均通过官方源验证
  • Architecture: HIGH — 现有代码已读,集成点清晰
  • Pitfalls: MEDIUM-HIGH — Pitfall 1-2 有 GitHub issue 背书Pitfall 3-4 基于 esp-dl 文档 + 通用嵌入式经验
  • Performance numbers: MEDIUM — 官方 latency 数字 HIGHFPS 上限是推算值,实测可能有 ±30% 偏差

Research date: 2026-04-17 Valid until: 2026-05-17esp-dl 为活跃项目30 天内可能发布 v3.3.0,届时需回查 release notes


集成点清单(供 Planner 使用)

新增文件

  1. main/face_tracker.h + main/face_tracker.cc — 新增,任务封装
  2. main/idf_component.yml — 追加 esp-dl + human_face_detect 依赖
  3. main/CMakeLists.txt — 在 set(SOURCES ...)"face_tracker.cc"

需修改文件

文件 行号 修改内容
main/uart_component.h 结尾 新增 void uart_send_face(int x, int y);
main/uart_component.cc 结尾 新增 uart_send_face 实现
main/boards/common/esp32_camera.h L22 class 内 新增 CaptureForDetection() + FrameRef struct
main/boards/common/esp32_camera.cc 类外实现 新增 CaptureForDetection() 实现(或将 frame_ 改为 protected 提供 getter
main/application.cc L358 Start() 结尾 调用 face_tracker_start()(在 StartNetwork 之后)
main/application.cc L704/714/726 不需要修改,现有 uart_send_string("idle"/"listening"/"speaking") 维持原状
main/Kconfig.projbuild Camera Configuration menu 结尾 新增 config XIAOZHI_ENABLE_FACE_TRACKINGbool, 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_offsetfallback 到 external.grove_read()

无需修改的组件

  • main/main.ccuart_init_component() 调用点不变
  • main/boards/bread-compact-wifi-s3cam/config.h — GPIO17/18 已在 uart_component.h 配置,无冲突
  • partitions/v2/16m.csv — 选方案 Arodata无需改动
  • sdkconfig.defaults.esp32s3 — 无需改动PSRAM 已启用)
  • 现有 display/display.cc L42 uart_send_string(emotion) — 保持
  • LVGL / 音频 / WiFi / WebSocket 等所有模块 — 保持

任务架构总结

任务名 Core 优先级 说明
main_event_loop 任意 3 8KB 现有,不变
audio_input 0 8 6KB 现有,不变(最高优先级)
audio_output 任意 4 4KB 现有,不变
LVGL port task 1 2 8KB默认 现有 lcd_display.cc:131 task_affinity=1
face_track(新增) 0 2 8KB 10 FPS 限频,与 LVGL 分离 Core

Core 分配理由:

  • Core 0 已跑main_event_loopP=3、audio_inputP=8
  • Core 1 已跑LVGL portP=2
  • face_track 放 Core 0 P=2不会抢占 audio_input高优先级不与 LVGL 在同一 Core 争抢
  • esp-dl 的 Conv2D 支持双核调度,会自动把计算分到 Core 1理论上可用两核