ESP32-P4 野外数据集采集系统:MIPI-CSI摄像头 + ESP-Hosted WiFi + IndexedDB浏览器端完整方案

📖本文约 3984 字 · 阅读需 15 分钟

项目背景

在边缘 AI 开发中,真实场景数据集的质量往往比模型结构更重要。训练一个能在野外识别垃圾类别的 MobileNet 检测器,如果只用 ImageNet 预训练权重做迁移学习,缺乏目标场景的真实数据,实际部署效果必然大打折扣。

本项目从零构建了一条完整的数据采集流水线:

OV5647 摄像头 → MIPI-CSI 接收 → ISP 管线处理 → 硬件 JPEG 编码 → HTTP 服务器 → 浏览器端 IndexedDB 持久化存储

选用微雪 ESP32-P4-WIFI6 开发板作为目标硬件,利用 ESP32-P4 内置的 MIPI-CSI 接口、硬件 ISP 管线、硬件 JPEG 编码器,配合 ESP32-C6 协处理器提供的 WiFi 6 连接,构建一个低成本、低功耗、可独立工作的野外图像采集终端。

关键指标:

指标数值
处理器ESP32-P4 双核 RISC-V @360MHz
摄像头OV5647 500万像素, MIPI-CSI 2-lane
输出格式JPEG, 1280×960, Q60-Q95
PSRAM32MB Octal SPI @200MHz
WiFiESP32-C6 WiFi 6, SDIO 4-bit @40MHz
JPEG 上限180KB per frame, 自动降质保护
MJPEG 帧率~5fps @Q60
浏览器存储IndexedDB, 支持 ZIP 批量导出

硬件架构全景

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
┌─────────────────────────────────────────────────────┐
│ ESP32-P4-WIFI6 开发板 │
│ │
│ ┌──────────────┐ MIPI-CSI 2-lane ┌────────┐ │
│ │ ESP32-P4 │◄───────────────────────│ OV5647 │ │
│ │ (主处理器) │ I2C: SCL=GPIO8, 500MP │ │
│ │ │ SDA=GPIO7 │ 摄像头 │ │
│ │ 360MHz │ └────────┘ │
│ │ 双核 RISC-V │ │
│ │ 32MB PSRAM │ SDIO 4-bit 40MHz ┌────────┐ │
│ │ 32MB Flash │◄──────────────────────►│ESP32-C6│ │
│ │ │ │MINI-1 │ │
│ │ JPEG 编码器 │ streaming mode │WiFi 6 │ │
│ │ ISP 管线 │ │BLE 5.3 │ │
│ └──────────────┘ └────────┘ │
│ │
BOOT 按键: GPIO35 (低电平, 内部上拉) │
│ 短按 < 2s → 触发拍摄 │
│ 长按 > 5s → 清除 WiFi 凭据并重启 │
└─────────────────────────────────────────────────────┘

为什么选 ESP32-P4?

  1. ESP32-P4 是目前 Espressif 唯一支持 MIPI-CSI 摄像头接口的高性能 MCU
  2. 内置 硬件 ISP 管线(Demosaic → AWB → Color Correction),无需 CPU 干预
  3. 内置 硬件 JPEG 编码器,1280×960 帧编码仅需几毫秒
  4. SDIO 4-bit 40MHz 高速外设接口,能与 ESP32-C6 协处理器高效通信

为什么 WiFi 要独立用 ESP32-C6?

ESP32-P4 自身没有 WiFi 射频。Espressif 的 ESP-Hosted 框架允许 P4 通过 SDIO/SPI/UART 连接一个 ESP32-C6 作为 WiFi/BLE 协处理器,C6 运行从机固件,P4 通过 RPC 调用 WiFi 功能。


软件架构分层

graph TB
    A[app_main.c<br/>入口: NVS→摄像头→WiFi→OTA→HTTP→按键] --> B[camera_hal.c<br/>V4L2 + ISP + HW JPEG]
    A --> C[wifi_manager.c<br/>STA/AP 自动切换]
    A --> D[ota_partition.c<br/>C6 从机分区 OTA]
    A --> E[test_server.c<br/>HTTP :80 + :81 + SSE]
    E --> F[web_ui.h<br/>内嵌 Web 前端]
    B --> G[esp_video V4L2<br/>/dev/video]
    B --> H[driver/jpeg_encode<br/>HW JPEG 编码器]
    C --> I[esp_wifi_remote<br/>ESP-Hosted RPC]
    D --> I
    I --> J[ESP32-C6 从机<br/>WiFi 6 / BLE 5.3]

各模块职责

模块文件职责
摄像头 HALcamera_hal.cV4L2 接口封装, ISP 管线控制, HW JPEG 编码, JPEG 尺寸保护
WiFi 管理器wifi_manager.cSTA/AP 双模, NVS 凭据存储, 自动回退配网
HTTP 服务器test_server.c端口 80 控制面 + 端口 81 流面, SSE 非阻塞推送
从机 OTAota_partition.cflash 分区读取, 版本比较, 分块 RPC 传输
Web 前端web_ui.h单 HTML 文件嵌入固件, MJPEG 预览, IndexedDB 相册

摄像头驱动深度解析

V4L2 数据管道

基于 esp_video 组件(V4L2 POSIX API),数据流如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 1. 打开设备
g_video_fd = open("/dev/video", O_RDWR);

// 2. 设置格式: RGB565 @1280x960
struct v4l2_format fmt = {
.type = V4L2_BUF_TYPE_VIDEO_CAPTURE,
.fmt.pix.width = 1280,
.fmt.pix.height = 960,
.fmt.pix.pixelformat = V4L2_PIX_FMT_RGB565, // ISP 输出 RGB565
};
ioctl(g_video_fd, VIDIOC_S_FMT, &fmt);

// 3. MMAP 双缓冲 (每帧 2,457,600 字节, 在 PSRAM 中)
struct v4l2_requestbuffers req = {
.count = 2,
.type = V4L2_BUF_TYPE_VIDEO_CAPTURE,
.memory = V4L2_MEMORY_MMAP,
};
ioctl(g_video_fd, VIDIOC_REQBUFS, &req);

// 4. 入队 → 流开启 → 出队 → HW JPEG 编码 → 入队

关键: g_pixel_format = V4L2_PIX_FMT_RGB565 (FourCC 0x50424752)。ISP 管线完成 RAW10 → RGB565 的转换,CPU 零参与。

HW JPEG 编码器

ESP32-P4 内置 JPEG 硬件加速器 (via driver/jpeg_encode.h):

1
2
3
4
5
6
7
8
9
10
11
12
13
// JPEG 编码器初始化
jpeg_new_encoder_engine(&jpeg_config, &g_jpeg_handle);

// 编码参数
g_jpeg_cfg.src_type = JPEG_ENCODE_IN_FORMAT_RGB565;
g_jpeg_cfg.src_type_size = 16; // RGB565 = 16 bit/pixel
g_jpeg_cfg.image_quality = 85;
g_jpeg_cfg.width = 1280;
g_jpeg_cfg.height = 960;

// 输出缓冲: 1280×960×2 = 2.5MB (PSRAM)
g_jpeg_out_size = g_current_width * g_current_height * 2;
g_jpeg_out_buf = heap_caps_malloc(g_jpeg_out_size, MALLOC_CAP_SPIRAM);

互斥锁策略

/capture(单帧拍摄,5s 超时)和 /stream(MJPEG 连续流,100ms 超时)共享同一个 g_io_lock 互斥锁:

1
2
3
4
g_io_lock ─ 保护 DQBUF → ISP → JPEG 编码 → QBUF 整个管道

/capture: xSemaphoreTake(timeout=5000ms), 阻塞等待
/stream: xSemaphoreTake(timeout=100ms), 拿不到则跳过本帧

效果:浏览器访问 /capture 时,MJPEG 流自动暂停一帧,不会死锁。

JPEG 尺寸保护机制

某些场景(高细节纹理)下,Q85 编码产生的 JPEG 可能超过 339KB,通过 WiFi 发送耗时超过 6 秒,触发 TCP 超时。解决思路:输出 size > 阈值 → 降质重编码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define MAX_JPEG_BYTES  180000  // 180KB cap

for (int attempt = 0; attempt < 3; attempt++) {
g_jpeg_cfg.image_quality = attempt_q;
enc_size = 0;
ret = jpeg_encoder_process(g_jpeg_handle, &g_jpeg_cfg,
g_mmap_buf[vbuf.index], raw_len,
g_jpeg_out_buf, g_jpeg_out_size, &enc_size);
if (ret != ESP_OK) break;
if (enc_size <= MAX_JPEG_BYTES || attempt_q <= JPEG_QUALITY_MIN) break;

// 超限: 每次降低 12 个 quality 点, 最多 3 次
attempt_q -= 12;
if (attempt_q < JPEG_QUALITY_MIN) attempt_q = JPEG_QUALITY_MIN;
ESP_LOGW(TAG, "JPEG %u bytes > %u, re-encoding at Q%d",
(unsigned)enc_size, (unsigned)MAX_JPEG_BYTES, attempt_q);
}

实测效果:一段 380KB 的帧经 3 次降质重编码后稳定在 183KB,发送成功。


ESP-Hosted WiFi 协处理器方案

SDIO 连接拓扑

ESP32-P4 与 ESP32-C6 通过 4-bit SDIO 物理连接,跑 streaming mode,时钟 40MHz。逻辑层使用 ESP-Hosted 框架:

1
2
3
4
5
6
7
8
ESP32-P4 (Host)                  ESP32-C6 (Slave)
┌──────────────┐ ┌──────────────┐
│ esp_wifi_remote│── SDIO ──► │ ESP-Hosted │
│ (RPC client) │ 4-bit 40MHz │ firmware │
│ │◄── CMD/DAT ── │ │
WiFi 调用 │ │ 真实 WiFi 驱动 │
│ TCP/IP 栈 │ │ LWIP 栈 │
└──────────────┘ └──────────────┘

STA/AP 双模自动切换

1
2
3
4
5
6
7
8
9
10
11
esp_err_t wifi_manager_init(void) {
// ...初始化 netif + event loop...

esp_err_t ret = start_sta_mode(); // 尝试用 NVS 凭据连接
if (ret == ESP_ERR_NOT_FOUND // 无凭据
|| ret == ESP_ERR_TIMEOUT) { // 连接超时
esp_wifi_stop();
ret = start_ap_mode(); // 回退到 AP 配网模式
}
return ret;
}

NVS 凭据管理

  • 命名空间:wifi_cfg
  • Key:ssid, password
  • STA 连接失败 10 次后自动清除凭据并重启进入 AP 模式
  • Web 前端 POST /config 保存凭据写入 NVS
1
2
3
4
5
6
7
8
// 保存凭据
nvs_set_str(handle, "ssid", ssid);
nvs_set_str(handle, "password", password);
nvs_commit(handle);

// 清除凭据 (长按 BOOT 键 5 秒触发)
nvs_erase_all(handle);
esp_restart();

C6 从机固件 OTA 远程更新

ESP32-C6 的固件存放在 P4 Flash 的 slave_fw 分区中,P4 上电时自动检测版本并执行 OTA。

分区表设计

1
2
3
4
5
6
# Name,      Type, SubType,  Offset,    Size
nvs, data, nvs, 0x9000, 0x6000 (24KB)
otadata, data, ota, 0xF000, 0x2000 (8KB)
phy_init, data, phy, 0x11000, 0x1000 (4KB)
factory, app, factory, 0x20000, 0x1E0000 (~1.9MB, P4 固件)
slave_fw, data, undefined,0x200000, 0x200000 (2MB, C6 固件)

OTA 流程

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
esp_err_t ota_partition_perform(const char *label) {
// 1. 找到 slave_fw 分区
const esp_partition_t *part = esp_partition_find_first(
ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_UNDEFINED, label);

// 2. 读取固件头, 解析 segment
esp_partition_read(part, 0, data, sizeof(esp_image_header_t));

// 3. 获取 C6 当前版本, 比较决定是否跳过
esp_hosted_get_coprocessor_fwversion(&c6_ver);

// 4. 分块写入 (CHUNK_SIZE=1500 bytes)
esp_hosted_slave_ota_begin();
for (addr = 0; addr < part->size; addr += CHUNK_SIZE) {
esp_partition_read(part, addr, chunk_data, CHUNK_SIZE);
esp_hosted_slave_ota_write(chunk_data, chunk_len);
}
esp_hosted_slave_ota_end();

// 5. 激活新固件并重启
esp_hosted_slave_ota_activate();
}

关键设计:版本比较跳过机制—C6 当前版本与分区固件版本相同则不重复刷写,避免每次上电都执行耗时的 OTA。


HTTP 服务器与 SSE 推送

非阻塞 SSE 设计

传统的 SSE handler 会在内部 while(true) 循环保持 HTTP 连接,这会绑定一个 httpd task 线程。对于资源有限的 MCU,我们采用非阻塞 SSE模式:

handler 发送初始 chunk 后立即返回,后续推送通过 httpd_socket_send() 从独立 task 执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// SSE handler — 发送初始 chunk 后立即返回
static esp_err_t events_handler(httpd_req_t *req) {
int fd = httpd_req_to_sockfd(req);
g_sse_fd = fd; // 记录 socket fd
g_sse_hd = req->handle; // 记录 server handle

httpd_resp_set_type(req, "text/event-stream");
httpd_resp_set_hdr(req, "Cache-Control", "no-cache");
httpd_resp_set_hdr(req, "Connection", "keep-alive");
httpd_resp_send_chunk(req, ": ok\n\n", 7); // 初始 keep-alive

return ESP_OK; // handler 返回, 连接保持 open
}

// 从 BOOT 按键 task 推送事件
void test_sse_notify_capture(void) {
if (g_sse_fd < 0 || g_sse_hd == NULL) return;
const char *event = "data: {\"type\":\"capture\"}\n\n";
httpd_socket_send(g_sse_hd, g_sse_fd, event, strlen(event), 0);
}

SO_LINGER 避免 TIME_WAIT 积压

默认的 TCP close 发送 FIN 包后进入 TIME_WAIT 状态,持续数分钟占用 socket。在 MCU 有限 socket 资源下(LWIP 最多 16 个),这会造成 “Address in use” 错误。通过 SO_LINGER {1,0} 直接发送 RST 立即释放:

1
2
3
4
5
6
7
8
static void close_fd_cb(httpd_handle_t hd, int sockfd) {
struct linger so_linger = { .l_onoff = 1, .l_linger = 0 };
setsockopt(sockfd, SOL_SOCKET, SO_LINGER, &so_linger, sizeof(so_linger));
close(sockfd);
}

// 注册到 httpd config
cfg.close_fn = close_fd_cb;

MJPEG 流 — 动态端口 81

流服务器按需启动(/stream_ctrl?action=start),独立在端口 81 运行,避免与端口 80 的控制请求争抢 socket:

1
2
3
4
5
6
7
8
// stream_ctrl_handler
httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
cfg.server_port = 81;
cfg.ctrl_port = 32769;
cfg.max_open_sockets = 3;
cfg.lru_purge_enable = true;
cfg.close_fn = close_fd_cb;
httpd_start(&g_stream_hd, &cfg);

流 handler 使用 100ms 锁超时 避让 capture 请求(5s 超时),二者共享硬件管道互不饿死:

1
2
3
4
5
6
7
8
9
10
// 流帧循环
while (true) {
if (camera_capture(&fb, &flen, 60, 100) != ESP_OK) {
vTaskDelay(pdMS_TO_TICKS(50));
continue; // 锁忙则跳过本帧
}
httpd_resp_send_chunk(sr, "\r\n--frame\r\n", 11);
// 发送 JPEG payload...
vTaskDelay(pdMS_TO_TICKS(100)); // ~10fps 理论, 实际 ~5fps
}

Web 前端:IndexedDB 持久化

前端作为一个完整的 HTML 字符串嵌入固件(web_ui.h 中的 MAIN_PAGE_HTML),约 26KB。通过 http://<设备IP> 直接访问。

功能矩阵:

功能实现方式
MJPEG 实时预览<img src="/stream">, 支持开/关切换
单张拍摄fetch("/capture") → Blob → IndexedDB
SSE 物理按钮触发EventSource("/events") → 自动拍摄
相册管理3 列 CSS Grid, 缩略图预览, 支持删除
全屏查看点击缩略图 Modal 展开, 下载按钮
ZIP 批量导出JSZip Stored 模式, dataset_YYYYMMDD_HHMMSS.zip
画质调整<input type="range"> 60-95, POST /quality?v=X
配网AP 模式下表单 POST /config

IndexedDB 存储结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 打开数据库
const db = await indexedDB.open('CameraDB', 1);
// 创建 object store
db.createObjectStore('images', { keyPath: 'id', autoIncrement: true });

// 存储图片
const tx = db.transaction('images', 'readwrite');
tx.objectStore('images').add({
jpeg: blob, // JPEG Blob
timestamp: Date.now(),
width: 1280,
height: 960
});

编译部署实操

1. 环境准备

1
2
3
# ESP-IDF v5.5.4 (v5.4+ 均可)
git clone -b v5.5.4 https://github.com/espressif/esp-idf.git
cd esp-idf && ./install.sh esp32p4 && . ./export.sh

2. 获取项目代码

1
2
git clone <repo-url>
cd Dataset_clcting_sys_ontargetdevice

3. 准备 C6 从机固件

将 ESP-Hosted 的 C6 从机固件烧录到 slave_fw 分区:

1
python -m esptool --chip esp32p4 --port COMx write_flash 0x200000 c6_firmware.bin

4. 编译与烧录

1
2
idf.py build                              # 首次编译, 自动下载依赖
idf.py -p COMx flash monitor # 烧录 + 串口监控

5. 首次配网

设备启动后无 WiFi 凭据,自动进入 AP 配网模式

  • 连接热点 CamSetup(无密码)
  • 浏览器访问 http://192.168.4.1
  • 填写 WiFi 信息 → 保存 → 设备自动重启连接

关键 sdkconfig 配置

1
2
3
4
5
6
7
8
9
10
11
12
13
CONFIG_IDF_TARGET="esp32p4"
CONFIG_SPIRAM_MODE_HEX=y
CONFIG_SPIRAM_SPEED_200M=y
CONFIG_ESPTOOLPY_FLASHSIZE_32MB=y
CONFIG_ESP_WIFI_REMOTE_ENABLED=y
CONFIG_SLAVE_IDF_TARGET_ESP32C6=y
CONFIG_CAMERA_OV5647=y
CONFIG_ESP_VIDEO_ENABLE_MIPI_CSI_VIDEO_DEVICE=y
CONFIG_ESP_VIDEO_ENABLE_ISP_PIPELINE_CONTROLLER=y
CONFIG_LWIP_MAX_SOCKETS=16
CONFIG_TCP_SND_BUF_DEFAULT=65535
CONFIG_TCP_WND_DEFAULT=65535
CONFIG_FREERTOS_HZ=1000

实战踩坑记录

坑 #1: GCC 嵌套函数 Trampoline 崩溃

症状: stream handler 作为嵌套函数定义在 stream_ctrl_handler 内部,父函数返回后,httpd 调用 handler 时触发 illegal instruction。

根因: GCC 对嵌套函数使用栈上的 trampoline 代码(可执行栈),父函数返回后 trampoline 所在的栈帧被回收。

修复: 将 stream handler 定义为文件作用域的 static 函数(stream_mjpeg_handler),在注册 URI handler 时通过函数指针传递。

这是 ESP-IDF + GCC 开发中最隐蔽的坑之一。务必所有 httpd URI handler 使用文件作用域函数。

坑 #2: Mutex 死锁导致 Capture 超时

症状: /capture 耗时 > 5 秒后返回 “capture failed”,且 /stream 同时停止工作。

根因: 流和 capture 使用同一个互斥锁 g_io_lock,流帧循环抢占锁后 capture 一直等待。原始代码使用统一超时,没有区分场景。

修复:camera_capture() 增加 timeout_ms 参数——stream 用 100ms(拿不到跳过),capture 用 5000ms(阻塞等待)。

坑 #3: JPEG 尺寸过大导致 TCP 超时

症状: 部分场景产生 339KB 的 JPEG,WiFi 发送超时 (> 6s),客户端收到空响应。

修复: 引入 MAX_JPEG_BYTES=180000 上限+逐级降质重编码(最多 3 次,每次降低 12 quality)。

坑 #4: COM8 端口被残留进程占用

症状: idf.py flash 报 “could not open port COM8”。

根因: 多次 idf.py monitor 被 Ctrl+C 中断后,Python 子进程未完全退出持续持有 COM 端口。

排查方法:

1
2
3
4
# 查找占用 COM8 的进程
Get-CimInstance Win32_Process -Filter "CommandLine like '%COM8%'" | Select-Object ProcessId, CommandLine
# 强制终止
Stop-Process -Id <PID> -Force

坑 #5: Windows GBK 编码错误

症状: idf.py flash 在 Windows 中文版上报 UnicodeEncodeError (GBK codec)。

绕过: 使用 python -m esptool 直接烧录,绕过 idf.py 的包装层。

坑 #6: DQBUF DONE 标志未置位

症状: 偶发捕获失败,日志显示 DQBUF: flags=0x0 (DONE not set)

修复: 检查 vbuf.flags & V4L2_BUF_FLAG_DONE,未置位则将 buffer 重新入队并返回错误,上层重试。

坑 #7: SSE 阻塞式 Handler 耗尽 Socket

症状: SSE 连接建立后,新请求报 “no socket available”。

根因: 传统 SSE handler 在内部 while(true) 循环,阻塞一个 httpd task 线程。多个 SSE 连接迅速耗尽 max_open_sockets

修复: 非阻塞 SSE — handler 发送初始 chunk 后立即 return ESP_OK,后续推送通过 httpd_socket_send() 从独立 task 进行。

坑 #8: 原 HTTPD_200 响应码 Bug

症状: httpd_resp_set_status(req, "200 OK") 导致 httpd 内部断言失败。

根因: httpd 默认状态码就是 200,显式调 set_status("200 OK") 会触发重复设置的检查。

修复: 移除所有 set_status("200 OK") 调用,只对非 200 响应(如 500, 400, 503)使用。


总结与展望

本项目从零构建了一个可直接用于野外数据采集的嵌入式系统,核心成果:

  1. 完整的 MIPI-CSI 摄像头驱动 — 基于 esp_video V4L2 + HW JPEG, 含 JPEG 尺寸保护
  2. 高可用 WiFi 方案 — ESP-Hosted SDIO + STA/AP 自动切换 + NVS 凭据管理
  3. C6 从机 OTA — 分区烧录 + 版本比较 + 分块 RPC 传输
  4. Web 全栈 — SSE 非阻塞推送 + MJPEG 实时流 + IndexedDB 持久化 + ZIP 导出

下一步计划:

  • 增加 OV5647 自动曝光/白平衡的运行时调节 API
  • 对接 TensorFlow Lite Micro 做板端实时推理(分类当前拍摄的物体类别)
  • 添加 LCD 触摸屏支持,实现脱离手机的独立手持设备
  • 支持 USB MSC 模式,直接将采集数据导出为 U 盘

项目地址

完整源代码及文档:E:\code\Dataset_clcting_sys_ontargetdevice

依赖组件版本(dependencies.lock 精密锁定,确保可复现构建):

组件版本用途
espressif/esp_video2.2.0V4L2 接口
espressif/esp_cam_sensor2.2.0OV5647 传感器驱动
espressif/esp_wifi_remote1.6.1WiFi 远程 RPC
espressif/esp_hosted2.12.9SDIO 传输层
espressif/esp_ipa2.1.0ISP 管线
espressif/esp_h2641.3.0H.264 编码器
espressif/esp_driver_jpeg(IDF 内置)HW JPEG 编码器
espressif/eppp_link1.1.5SDIO 底层链路

ESP32-P4 野外数据集采集系统:MIPI-CSI摄像头 + ESP-Hosted WiFi + IndexedDB浏览器端完整方案
https://brightnewmoon.top/2026/06/25/ESP32-P4-Field-Data-Collection/
作者
BrightNewMoon
发布于
2026年6月25日
许可协议
分享
微信扫码分享

打开微信「扫一扫」