# 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，理论上可用两核
