diff --git a/Coglet项目分析与开发指南.md b/Coglet项目分析与开发指南.md index b195a8d..015478b 100644 --- a/Coglet项目分析与开发指南.md +++ b/Coglet项目分析与开发指南.md @@ -422,7 +422,8 @@ ESP32 通过 UART 发送状态字符串给 RP2040: ### 4.9 舵机选型说明(重要) -> **实测踩坑记录**:使用 MG90S 360° 连续旋转版舵机后,耳朵舵机转到目标角度后无法停止,持续堵转导致齿轮发出刺耳声音、舵机严重发烫,有烧毁风险。更换为 180° 标准舵机后问题解决。 +> **实测踩坑记录**:使用 MG90S 360° 连续旋 +转版舵机后,耳朵舵机转到目标角度后无法停止,持续堵转导致齿轮发出刺耳声音、舵机严重发烫,有烧毁风险。更换为 180° 标准舵机后问题解决。 #### 必须使用 180° 标准舵机的原因 @@ -669,6 +670,145 @@ MicroPython 固件刷入方式与摄像头版本相同(参见 4.6),但 **R --- +## 六点六、Phase 01 核心矛盾与解决方案分析(2026-04-21 ~ 04-22) + +> 基于 JPEG Dump 诊断工具的大量实验(9 次迭代尝试),本节汇总当前主要矛盾、根本原因和方案选择。 + +### 6.6.1 主要矛盾:画面可辨识 ≠ 模型可识别 + +**诊断工具**:在 face_tracker.cc 里加 JPEG Dump 代码,每次启动 base64 打印一帧 JPEG,Mac 端 Python 脚本抓取保存为 `.jpg` 文件肉眼验证。 + +**验证结果**: + +| 观察 | 事实 | +|------|------| +| 手动撕掉镜头保护膜后,JPEG 画面可**清晰看到戴眼镜的人脸、手部、背景** | ✅ 摄像头硬件 + 飞线 + DVP 通路完全正常 | +| 画面**整体偏紫绿**(RGB565 模式)或**偏绿**(YUV422 模式) | 🟡 软件层 YUV→RGB 色彩矩阵偏差,不是硬件问题 | +| 同样的摄像头输入,esp-dl `HumanFaceDetect` **无论什么 pix_type 都输出固定 box** | ❌ 深层集成问题 | + +**核心矛盾**: + +> 人眼能辨识的画面(因为有上下文知识"绿色的这个 = 人脸"),轻量级 CNN 模型无法识别(只看像素数值分布)。esp-dl 官方模型用**正常色彩的标准人脸数据集**训练,我们的偏色画面在训练集里找不到对应模式 → 模型 fallback 到默认 anchor → box 恒定。 + +### 6.6.2 YUV→RGB 色偏的三层原因 + +#### 第 1 层:**YUV→RGB 色彩矩阵公式不完全匹配 BT.601** + +- OV3660 输出 YUV **限幅范围**:Y ∈ [16, 235], U/V ∈ [16, 240](中值 128) +- 手写转换公式假定 Y ∈ [0, 255] **全范围**(JFIF 标准): + ```cpp + int r = Y + 1.402 * (V - 128); // 错:没有黑电平偏移,整体偏暗 + ``` +- 正确应为(BT.601): + ```cpp + int y_scaled = 1.164 * (Y - 16); // 减黑电平、放大到全范围 + int r = y_scaled + 1.596 * (V - 128); + ``` + +#### 第 2 层:**OV3660 AWB(自动白平衡)未启用或响应慢** + +- 默认寄存器序列中,AWB 可能关闭或慢响应 +- 导致 U/V 有**全局偏移**:画面整体偏绿/紫 +- Grove Vision AI V2 内置 ISP 硬件自动白平衡,**我们读原始 YUV buffer 没有** + +#### 第 3 层:**OV3660 FORMAT_CTRL00 = 0x61 的实际含义** + +- `bit[7:4] = 0x6` = RGB565 +- `bit[3:0] = 0x1` = byte-swap 序列 +- 在 Kconfig RGB565 模式下,sensor 实际输出可能是 **YVYU sequence**(Y-V-Y-U)而非标准 YUYV,导致 U/V 解读时互换 +- 修正方向:在 Kconfig 改用 YUV422 模式(FORMAT_CTRL00=0x30,标准 YUYV) + +### 6.6.3 esp-dl 模型输入分布敏感 + +即使色彩完全校正正确,轻量级模型(MSR_S8_V1 仅 60KB、ESPDET_PICO_224_224_FACE 约 500KB)对 RGB 分布偏差**极其敏感**。具体要求: + +| 要求 | 解释 | +|------|------| +| RGB 通道均值接近训练集 | ImageNet 类数据集 RGB 均值约 (124, 116, 104) | +| 归一化范围精确 | ESPDET 用 `(pixel-0)/255`,要求 pixel ∈ [0, 255] 全范围 | +| 无严重色偏 | 偏绿会让模型前几层卷积产生"异常激活",后续全部 fallback | +| 无边缘伪影 | letterbox 填充不能和画面内容对比度过强 | + +> **为什么 Grove Vision AI V2 一定能行**:Grove 用 Himax WiseEye HX6538 专用 AI 视觉处理器,内置 ISP + 针对自己 sensor 训练的**专用人脸检测模型**,从硬件到模型端到端自闭环。esp-dl 是通用框架,需要用户自己保证数据质量。 + +### 6.6.4 sensor 硬件 JPEG 模式的局限 + +OV3660 支持硬件 JPEG 编码(`CAMERA_OV3660_DVP_JPEG_1280X720_12FPS`),但实测失败: + +- `Esp32Camera::Capture()` 默认不协商 JPEG pix_fmt,报 `no supported pixel format found` +- 启用 `CONFIG_XIAOZHI_CAMERA_ALLOW_JPEG_INPUT=y` 后能协商,但 `bytesused=0` —— DMA 没采到 JPEG 帧 +- 推测 sensor 硬件 JPEG 需要特殊的 DVP 帧同步处理,xiaozhi 的 V4L2 mmap 路径不兼容 + +结论:硬件 JPEG 路径此项目未打通,**需走软件 JPEG 编解码**。 + +### 6.6.5 延迟分析:JPEG 中转路径 + +走 `xiaozhi Capture() → JPEG → esp-dl sw_decode_jpeg → RGB888 → HumanFaceDetect` 路径的延迟估算: + +| 阶段 | 耗时 | 说明 | +|------|------|------| +| 摄像头采集一帧 | ~40ms | 24 FPS 间隔 | +| xiaozhi Capture() 软件 JPEG 编码 | 50-80ms | 240×240 YUV→RGB→JPEG | +| esp-dl sw_decode_jpeg 解码 | 30-50ms | JPEG → RGB888 | +| HumanFaceDetect 模型推理 | 150ms | ESPDET_PICO_224 | +| UART 发送坐标 | 1ms | 240 bytes @ 115200 | +| **ESP32 端总延迟** | **~270ms** | | +| RP2040 UART RX + parse | 2ms | | +| 舵机 PWM + 物理转动 | 20-80ms | 机械响应时间 | +| **端到端总延迟(脸动→眼球动)** | **~300-350ms** | | + +**对比**: +- **人眼感知"流畅跟随"阈值**:< 500ms +- **Grove Vision AI V2**:~100-150ms(专用硬件) +- **JPEG 中转方案**:~300ms ✅ 可接受 +- **人眨眼速度**:~400ms + +### 6.6.6 三个路径选择 + +| 方案 | 预计工时 | 成功率 | 备注 | +|------|---------|-------|------| +| **A. 继续深挖 esp-dl(改色彩矩阵、启 AWB、fork 预处理)** | 8-10 小时 | ⭐⭐(20-30%)| 涉及 ov3660 寄存器调优 + 模型内部调试 | +| **B. JPEG 中转路径(走 xiaozhi 完整 Capture + esp-dl sw_decode_jpeg)** | 2-3 小时 | ⭐⭐⭐⭐(70-80%)| **推荐**。`take_photo` 已证明 Capture() 色彩正常 | +| **C. 退回 Grove Vision AI V2(项目原设计)** | 2 小时 + ¥200 | ⭐⭐⭐⭐⭐(100%)| 官方 turnkey 方案,稳妥 | + +### 6.6.7 方案 B 的关键突破口 + +**关键发现**:xiaozhi 的 `self.camera.take_photo` MCP 功能拍的照片**云端 AI 能清晰识别**,说明 `Capture()` 函数内部有正确的色彩处理(白平衡、色彩矩阵、JPEG 标准编码)。 + +**未尝试的真正路径**: +``` +Esp32Camera::Capture() + ↓ 内部完整 pipeline(色彩正常的 JPEG) +JPEG buffer + ↓ esp-dl sw_decode_jpeg(esp-dl 官方 example 路径) +标准 RGB888 画面(色彩 100% 匹配训练集) + ↓ +HumanFaceDetect → 真正的 box +``` + +**之前的失败路径**:我一直绕过 `Capture()` 用 `CaptureForDetection()` 直接拿 V4L2 mmap 的原始 YUV buffer,缺少 xiaozhi 的色彩校正。 + +### 6.6.8 验证方案 B 可行性的最简方法 + +**不用改代码**: + +1. 烧录当前 YUV422 模式的固件 +2. 通过小智对话说"**帮我拍张照看看**"或"**你看见什么了**" +3. AI 云端返回画面描述 + +- 如果 AI 说能**清晰看到人脸/房间物体** → 证明 `Capture()` 色彩正常 → **方案 B 可行性 80%+** +- 如果 AI 说看不清或描述错乱 → `Capture()` 也有色偏 → 需考虑方案 C + +### 6.6.9 当前代码状态快照(2026-04-22) + +- `main/face_tracker.cc`:手动 YUYV→RGB888 转换 + pix_type=RGB888(失败路径,保留代码) +- `main/face_tracker.cc`:JPEG Dump 诊断代码(每次启动拍一张 YUYV JPEG) +- `sdkconfig`:`CAMERA_OV3660_DVP_YUV422_240X240_24FPS=y`(画面偏绿但结构清晰) +- 固件编译通过,烧录正常,face_tracker 启动正常 +- 症状:box 恒定 `[233, 158, 94, 239]`,眼球卡在极限位置不跟随 + +--- + ## 七、参考资源 | 资源 | 地址 | diff --git a/docs/ESP32-S3-CAM:接ov3660摄像头-CSDN博客.html b/docs/ESP32-S3-CAM:接ov3660摄像头-CSDN博客.html new file mode 100644 index 0000000..aaba776 --- /dev/null +++ b/docs/ESP32-S3-CAM:接ov3660摄像头-CSDN博客.html @@ -0,0 +1,2666 @@ + + + + + + + + + + + + + + + + + ESP32-S3-CAM:接ov3660摄像头-CSDN博客 + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+
+ +
+ +
+
+
+
+

ESP32-S3-CAM:接ov3660摄像头

+
+ +
+
+
+
+
+
+
+ CAM++一个可以将说话人语音识别的系统 构建by科哥 +
+
+
+

CAM++一个可以将说话人语音识别的系统 构建by科哥

+
+
AI应用
+
语音识别
+
PyTorch
+
+
+

CAM++一个可以将说话人语音识别的系统 构建by科哥

+
+
+
+ +
+
+
+
+
+ + +
+

越来越意识到,要接硬件之前要先了解这些硬件小伙伴,其实一开始我自己连怎么看摄像头是不是ov3660都不知道,接排线也是恍然大悟。

+ +

Part 1 认识摄像头并连接到主板上

+ +

1、认识摄像头

+ +

+ +

2、通过FPC柔性排线座连接摄像头

+ +

第一步先接摄像头,买到的是散件,摄像头和板子是分开的,一定要金色金属面朝下,然后塞进去白色卡座里,再卡住。

+ +

1)黑色夹片先用手往上掰起来

+ +

+ +

2)摄像头排线金色面朝下,黑色面朝上,插入卡槽中间,两边是白色底座,大概可以插进去2个毫米左右

+ +

+ +

3)黑色夹片扣下扣紧即可

+ +

+ +

三、认识柔性排线座

+ +

突然发现那个黑色卡子上有文字,44和24P,查了下,又涨知识了,原来那个黑色卡子的名字叫 柔性排线座。

+ +

这是FPC/FFC连接器(柔性排线座)的规格标识

+ +

标识解读

+ +
标识含义说明
44排线宽度 4.4mm指FFC/FPC排线的总宽度(含导体部分)
24P24 Pin(24针脚)连接器有24个金属触点/引脚
+ +

具体含义

+ +

44(4.4mm宽度)

+ +
  • +

    这是排线规格代码,表示适配的FFC/FPC软排线总宽度为 4.4mm

    +
  • +

    常见的还有 5.0mm、6.0mm、8.0mm 等规格

    +
  • +

    这个尺寸必须匹配,否则排线插不进去或接触不良

    +
+ +

24P(24 Pins)

+ +
  • +

    表示这个连接器有 24个引脚/通道

    +
  • +

    用于连接摄像头模组的24根信号线(包括电源、地线、MIPI/CSI数据 lanes、时钟、控制信号等)

    +
+ +

关于这个连接器

+ +

这是 ESP32-S3-CAM 的摄像头接口座,用于连接 OV2640/OV5640 等摄像头模组。

+ +

ESP32-S3-CAM 通常有两个独立的接口:

+ +
  • +

    📷 摄像头接口(图片中的这个,24Pin FPC座)

    +
  • +

    💾 SD卡槽(一般在板子另一侧,通常是自弹式或推入式卡槽)

    +
+ +

常见摄像头排线规格对照

+ +
标识排线宽度常见用途
44-24P4.4mm / 24pinOV2640 小模组
50-24P5.0mm / 24pinOV5640 等
60-30P6.0mm / 30pin高分辨率模组
+ +

Part 2 写摄像头自检代码

+ +

1、自检功能定义

+ +

功能:每5秒钟拍一张照,然后通过局域网显示查看拍的照片

+ +

其实一开始我也不知道居然可以用局域网打开个网址查看摄像头拍的照片,偶然一次通过AI给我一段代码检测摄像头的时候发现的,居然还有这样的牛逼功能。前提又要增加一个联WiFi的功能。

+ +

2、WiFi注意事项

+ +

注意:用手机开个热点就可以,但是WiFi热点必须是2.4G的,ESP32-S3这个型号的板子都只支持2.4G频段,WiFi命名和密码都不要太高级,简单点就行,小写字母加数字组合足够,比如我自己的设置的:WiFi名称:  fksijie 密码:112233445566

+ +

3、用AI写自检代码

+ +

热点设置好以后,让AI写一段摄像头自检代码,AI建议用Kimi写代码,豆包经过我的验证,成功率比较低,用多了Kimi后发现,居然免费版每天还有问题个数上限,没办法,选择了第一个付费档位充值一个月,为了节约时间,花点钱也就花点钱吧!!!

+ +

提示词:我用ESP32-S3-CAM连接了一个ov3660摄像头,给我一段自检代码,每五秒拍一张照,通过局域网查看拍到的照片

+ +
+#include <Arduino.h>
+#include <WiFi.h>
+#include <WebServer.h>
+#include <esp_camera.h>
+
+// ========== AI-Thinker ESP32-S3-CAM 引脚配置 ==========
+#define CAM_PWDN    -1
+#define CAM_RESET   -1
+#define CAM_XCLK    15
+#define CAM_SIOD    4
+#define CAM_SIOC    5
+#define CAM_D7      16
+#define CAM_D6      17
+#define CAM_D5      18
+#define CAM_D4      12
+#define CAM_D3      10
+#define CAM_D2      8
+#define CAM_D1      9
+#define CAM_D0      11
+#define CAM_VSYNC   6
+#define CAM_HREF    7
+#define CAM_PCLK    13
+
+// ========== Freenove ESP32-S3-CAM 引脚配置 ==========
+// #define CAM_PWDN    -1
+// #define CAM_RESET   -1
+// #define CAM_XCLK    15
+// #define CAM_SIOD    4
+// #define CAM_SIOC    5
+// #define CAM_D7      11   // Freenove: D7=GPIO11 (AI-Thinker: GPIO16)
+// #define CAM_D6      9    // Freenove: D6=GPIO9  (AI-Thinker: GPIO17)
+// #define CAM_D5      8    // Freenove: D5=GPIO8  (AI-Thinker: GPIO18)
+// #define CAM_D4      10   // Freenove: D4=GPIO10 (AI-Thinker: GPIO12)
+// #define CAM_D3      12   // Freenove: D3=GPIO12 (AI-Thinker: GPIO10)
+// #define CAM_D2      18   // Freenove: D2=GPIO18 (AI-Thinker: GPIO8)
+// #define CAM_D1      17   // Freenove: D1=GPIO17 (AI-Thinker: GPIO9)
+// #define CAM_D0      16   // Freenove: D0=GPIO16 (AI-Thinker: GPIO11)
+// #define CAM_VSYNC   6
+// #define CAM_HREF    7
+// #define CAM_PCLK    13
+
+// 替换为您的 WiFi 信息
+const char* ssid = "fksijie";
+const char* password = "112233445566";
+
+WebServer server(80);
+
+// 照片存储缓冲区
+camera_fb_t * fb = NULL;
+
+// 拍照时间控制
+unsigned long lastCaptureTime = 0;
+const unsigned long captureInterval = 5000; // 10秒间隔
+
+// 最新照片数据
+uint8_t* lastPhotoBuffer = NULL;
+size_t lastPhotoLength = 0;
+
+void setup() {
+  Serial.begin(115200);
+  delay(1000);
+  
+  Serial.println("ESP32-S3-CAM OV3660 启动中...");
+  
+  // 初始化摄像头
+  if (!initCamera()) {
+    Serial.println("摄像头初始化失败,重启...");
+    delay(2000);
+    ESP.restart();
+  }
+  
+  // 连接 WiFi
+  WiFi.begin(ssid, password);
+  Serial.print("连接 WiFi");
+  while (WiFi.status() != WL_CONNECTED) {
+    delay(500);
+    Serial.print(".");
+  }
+  Serial.println();
+  Serial.print("WiFi 已连接,IP: ");
+  Serial.println(WiFi.localIP());
+  
+  // 设置 Web 服务器路由
+  server.on("/", handleRoot);
+  server.on("/capture", handleCapture);
+  server.on("/photo", handlePhoto);
+  server.on("/stream", handleStream);
+  
+  server.begin();
+  Serial.println("Web 服务器已启动");
+  Serial.print("访问 http://");
+  Serial.println(WiFi.localIP());
+}
+
+void loop() {
+  server.handleClient();
+  
+  // 每隔10秒自动拍照
+  unsigned long currentTime = millis();
+  if (currentTime - lastCaptureTime >= captureInterval) {
+    lastCaptureTime = currentTime;
+    capturePhoto();
+    Serial.println("自动拍照完成");
+  }
+}
+
+// ========== 摄像头初始化 ==========
+bool initCamera() {
+  camera_config_t config;
+  config.ledc_channel = LEDC_CHANNEL_0;
+  config.ledc_timer = LEDC_TIMER_0;
+  config.pin_pwdn = CAM_PWDN;
+  config.pin_reset = CAM_RESET;
+  config.pin_xclk = CAM_XCLK;
+  config.pin_sccb_sda = CAM_SIOD;
+  config.pin_sccb_scl = CAM_SIOC;
+  config.pin_d7 = CAM_D7;
+  config.pin_d6 = CAM_D6;
+  config.pin_d5 = CAM_D5;
+  config.pin_d4 = CAM_D4;
+  config.pin_d3 = CAM_D3;
+  config.pin_d2 = CAM_D2;
+  config.pin_d1 = CAM_D1;
+  config.pin_d0 = CAM_D0;
+  config.pin_vsync = CAM_VSYNC;
+  config.pin_href = CAM_HREF;
+  config.pin_pclk = CAM_PCLK;
+  
+  config.xclk_freq_hz = 20000000;  // 20MHz XCLK
+  config.pixel_format = PIXFORMAT_JPEG;  // JPEG 格式
+  
+  // OV3660 支持 3MP (2048x1536),但建议先用较低分辨率测试
+  config.frame_size = FRAMESIZE_UXGA;  // 1600x1200,可改为 FRAMESIZE_SXGA (1280x1024)
+  config.jpeg_quality = 12;  // 0-63,越低质量越高
+  config.fb_count = 2;  // 双缓冲
+  
+  // 初始化摄像头
+  esp_err_t err = esp_camera_init(&config);
+  if (err != ESP_OK) {
+    Serial.printf("摄像头初始化失败,错误码: 0x%x\n", err);
+    return false;
+  }
+  
+  // 获取传感器对象,调整设置
+  sensor_t * s = esp_camera_sensor_get();
+  if (s) {
+    // 水平翻转(根据实际需要调整)
+    s->set_hmirror(s, 0);
+    // 垂直翻转
+    s->set_vflip(s, 0);
+    // 亮度 -2 到 2
+    s->set_brightness(s, 0);
+    // 对比度 -2 到 2
+    s->set_contrast(s, 0);
+    // 饱和度 -2 到 2
+    s->set_saturation(s, 0);
+    
+    // 如果是 OV3660,可以设置更高增益
+    s->set_gainceiling(s, GAINCEILING_16X);
+    
+    Serial.println("摄像头传感器配置完成");
+    Serial.printf("传感器 PID: 0x%x\n", s->id.PID);
+  }
+  
+  Serial.println("摄像头初始化成功");
+  return true;
+}
+
+// ========== 拍照函数 ==========
+void capturePhoto() {
+  // 释放之前的缓冲区
+  if (lastPhotoBuffer != NULL) {
+    free(lastPhotoBuffer);
+    lastPhotoBuffer = NULL;
+  }
+  
+  // 获取一帧
+  fb = esp_camera_fb_get();
+  if (!fb) {
+    Serial.println("拍照失败:获取帧缓冲区失败");
+    return;
+  }
+  
+  // 复制照片数据
+  lastPhotoLength = fb->len;
+  lastPhotoBuffer = (uint8_t*)malloc(lastPhotoLength);
+  if (lastPhotoBuffer != NULL) {
+    memcpy(lastPhotoBuffer, fb->buf, lastPhotoLength);
+    Serial.printf("拍照成功: %d bytes, 分辨率: %dx%d\n", 
+      lastPhotoLength, fb->width, fb->height);
+  } else {
+    Serial.println("内存分配失败");
+  }
+  
+  // 释放帧缓冲区
+  esp_camera_fb_return(fb);
+  fb = NULL;
+}
+
+// ========== Web 页面:主页 ==========
+void handleRoot() {
+  String html = R"rawliteral(
+<!DOCTYPE html>
+<html>
+<head>
+  <meta charset="UTF-8">
+  <meta name="viewport" content="width=device-width, initial-scale=1.0">
+  <title>ESP32-S3-CAM OV3660</title>
+  <style>
+    body {
+      font-family: Arial, sans-serif;
+      max-width: 800px;
+      margin: 0 auto;
+      padding: 20px;
+      background: #1a1a1a;
+      color: #fff;
+      text-align: center;
+    }
+    h1 { color: #00d4ff; }
+    .photo-container {
+      margin: 20px 0;
+      background: #2a2a2a;
+      padding: 20px;
+      border-radius: 10px;
+      min-height: 300px;
+    }
+    img {
+      max-width: 100%;
+      height: auto;
+      border-radius: 5px;
+      box-shadow: 0 4px 8px rgba(0,0,0,0.3);
+    }
+    .info {
+      color: #aaa;
+      margin: 10px 0;
+    }
+    .btn {
+      background: #00d4ff;
+      color: #000;
+      border: none;
+      padding: 12px 24px;
+      font-size: 16px;
+      border-radius: 5px;
+      cursor: pointer;
+      margin: 5px;
+      transition: 0.3s;
+    }
+    .btn:hover { background: #00a8cc; }
+    .status {
+      display: inline-block;
+      width: 10px;
+      height: 10px;
+      border-radius: 50%;
+      background: #0f0;
+      margin-right: 5px;
+      animation: pulse 2s infinite;
+    }
+    @keyframes pulse {
+      0%, 100% { opacity: 1; }
+      50% { opacity: 0.3; }
+    }
+    #timer {
+      font-size: 24px;
+      color: #00d4ff;
+      margin: 10px 0;
+    }
+  </style>
+</head>
+<body>
+  <h1>📷 ESP32-S3-CAM OV3660</h1>
+  <div class="info">
+    <span class="status"></span>自动拍照中(每5秒)
+  </div>
+  <div id="timer">下次拍照: 5秒</div>
+  
+  <div class="photo-container">
+    <img id="photo" src="/photo" alt="最新照片">
+  </div>
+  
+  <div>
+    <button class="btn" onclick="refreshPhoto()">🔄 立即刷新</button>
+    <button class="btn" onclick="toggleAuto()">⏸ 暂停/继续</button>
+    <button class="btn" onclick="captureNow()">📸 立即拍照</button>
+  </div>
+  
+  <div class="info" id="photoInfo">等待照片...</div>
+
+  <script>
+    let autoRefresh = true;
+    let countdown = 5;
+    
+    // 自动倒计时
+    setInterval(() => {
+      if (autoRefresh && countdown > 0) {
+        countdown--;
+        document.getElementById('timer').textContent = '下次拍照: ' + countdown + '秒';
+      }
+      if (countdown === 0) {
+        refreshPhoto();
+        countdown = 5;
+      }
+    }, 1000);
+    
+    // 刷新照片
+    function refreshPhoto() {
+      const img = document.getElementById('photo');
+      const timestamp = new Date().getTime();
+      img.src = '/photo?t=' + timestamp;
+      document.getElementById('photoInfo').textContent = '更新时间: ' + new Date().toLocaleTimeString();
+    }
+    
+    // 立即拍照
+    function captureNow() {
+      fetch('/capture')
+        .then(r => r.text())
+        .then(t => {
+          countdown = 5;
+          refreshPhoto();
+        });
+    }
+    
+    // 暂停/继续自动刷新
+    function toggleAuto() {
+      autoRefresh = !autoRefresh;
+      document.getElementById('timer').style.display = autoRefresh ? 'block' : 'none';
+    }
+    
+    // 初始加载
+    refreshPhoto();
+  </script>
+</body>
+</html>
+)rawliteral";
+
+  server.send(200, "text/html", html);
+}
+
+// ========== Web 页面:立即拍照 ==========
+void handleCapture() {
+  capturePhoto();
+  if (lastPhotoBuffer != NULL) {
+    server.send(200, "text/plain", "拍照成功: " + String(lastPhotoLength) + " bytes");
+  } else {
+    server.send(500, "text/plain", "拍照失败");
+  }
+}
+
+// ========== Web 页面:获取照片 ==========
+void handlePhoto() {
+  if (lastPhotoBuffer == NULL) {
+    server.send(404, "text/plain", "暂无照片,请等待自动拍照或手动触发");
+    return;
+  }
+  
+  server.sendHeader("Cache-Control", "no-cache, no-store, must-revalidate");
+  server.sendHeader("Pragma", "no-cache");
+  server.sendHeader("Expires", "0");
+  server.send_P(200, "image/jpeg", (const char*)lastPhotoBuffer, lastPhotoLength);
+}
+
+// ========== Web 页面:视频流(MJPEG) ==========
+void handleStream() {
+  WiFiClient client = server.client();
+  
+  String response = "HTTP/1.1 200 OK\r\n";
+  response += "Content-Type: multipart/x-mixed-replace; boundary=frame\r\n\r\n";
+  client.print(response);
+  
+  while (client.connected()) {
+    fb = esp_camera_fb_get();
+    if (!fb) continue;
+    
+    response = "--frame\r\n";
+    response += "Content-Type: image/jpeg\r\n\r\n";
+    client.print(response);
+    client.write(fb->buf, fb->len);
+    client.print("\r\n");
+    
+    esp_camera_fb_return(fb);
+    delay(100);  // 约10fps
+  }
+}
+ +

4、摄像头引脚配置

+ +

代码开头就是一堆的变量定义,一开始我也不知道是什么意思,AI解释后才知道,是GPIO口的定义,这里我才逐渐醒悟过来,从淘宝店买板子的时候,一般会介绍哪个针脚接主板上哪个引脚,但是SD卡和摄像头,是没有介绍的,可能是要查看具体的引脚定义,这里一开始我不知道,摄像头引脚都是靠运气试出来的,后来我才发现,原来ESP32-S3这类板子有两种类型,一种是AI-Thinker版本,一种是Freenove ,AI的解释如下:

+ +

Freenove 有两款产品 :

+ +
  • +

    ESP32-S3-WROOM:带摄像头接口,但引脚定义与 AI-Thinker 不同

    +
  • +

    ESP32-S3-CAM:通常指 AI-Thinker 形式的板子

    +
+ +

+ +

从板子上看到印的是ESP32-S3-CAM

+ +

所以摄像头的参数配置应该按照 AI-Thinker版本来配置,不能按照Freenove版本来配置,二者在控制信号上相同,但是数据段不同。

+ +
Freenove vs AI-Thinker 引脚差异对比
+ +
引脚AI-ThinkerFreenove说明
CAM_D7GPIO 16GPIO 11⚠️ 不同
CAM_D6GPIO 17GPIO 9⚠️ 不同
CAM_D5GPIO 18GPIO 8⚠️ 不同
CAM_D4GPIO 12GPIO 10⚠️ 不同
CAM_D3GPIO 10GPIO 12⚠️ 不同
CAM_D2GPIO 8GPIO 18⚠️ 不同
CAM_D1GPIO 9GPIO 17⚠️ 不同
CAM_D0GPIO 11GPIO 16⚠️ 不同
CAM_XCLKGPIO 15GPIO 15✅ 相同
CAM_SIODGPIO 4GPIO 4✅ 相同
CAM_SIOCGPIO 5GPIO 5✅ 相同
CAM_VSYNCGPIO 6GPIO 6✅ 相同
CAM_HREFGPIO 7GPIO 7✅ 相同
CAM_PCLKGPIO 13GPIO 13✅ 相同
+ +

AI给我做了个对比,因为手头没有丝印是ESP32-S3-WROOM的板子,所以也没法验证。

+ +

但经过测试,确实用AI-Thinker 标准这套引脚定义的参数才能运行成功,用Freenove这套参数会提示   拍照失败:获取帧缓冲区失败

+ +
特征我这块板子真正的 Freenove ESP32-S3-WROOM
摄像头数据线D0-D7 使用 GPIO 8-12,16-18 (AI-Thinker 标准)D0-D7 使用 GPIO 8,9,10,11,12,16,17,18 (顺序不同) 
控制信号GPIO 4,5,6,7,13,15 (与 Freenove 相同)GPIO 4,5,6,7,13,15
+ +

这个时候再来看看代码里引脚的定义:

+ +
+// ========== AI-Thinker ESP32-S3-CAM 引脚配置 ==========
+#define CAM_PWDN    -1
+#define CAM_RESET   -1
+#define CAM_XCLK    15
+#define CAM_SIOD    4
+#define CAM_SIOC    5
+#define CAM_D7      16
+#define CAM_D6      17
+#define CAM_D5      18
+#define CAM_D4      12
+#define CAM_D3      10
+#define CAM_D2      8
+#define CAM_D1      9
+#define CAM_D0      11
+#define CAM_VSYNC   6
+#define CAM_HREF    7
+#define CAM_PCLK    13
+
+// ========== Freenove ESP32-S3-CAM 引脚配置 ==========
+// #define CAM_PWDN    -1
+// #define CAM_RESET   -1
+// #define CAM_XCLK    15
+// #define CAM_SIOD    4
+// #define CAM_SIOC    5
+// #define CAM_D7      11   // Freenove: D7=GPIO11 (AI-Thinker: GPIO16)
+// #define CAM_D6      9    // Freenove: D6=GPIO9  (AI-Thinker: GPIO17)
+// #define CAM_D5      8    // Freenove: D5=GPIO8  (AI-Thinker: GPIO18)
+// #define CAM_D4      10   // Freenove: D4=GPIO10 (AI-Thinker: GPIO12)
+// #define CAM_D3      12   // Freenove: D3=GPIO12 (AI-Thinker: GPIO10)
+// #define CAM_D2      18   // Freenove: D2=GPIO18 (AI-Thinker: GPIO8)
+// #define CAM_D1      17   // Freenove: D1=GPIO17 (AI-Thinker: GPIO9)
+// #define CAM_D0      16   // Freenove: D0=GPIO16 (AI-Thinker: GPIO11)
+// #define CAM_VSYNC   6
+// #define CAM_HREF    7
+// #define CAM_PCLK    13
+ +

5、代码运行结果

+ +
+Build:Mar 27 2021
+rst:0x1 (POWERON),boot:0x8 (SPI_FAST_FLASH_BOOT)
+SPIWP:0xee
+mode:DIO, clock div:1
+load:0x3fce3818,len:0x508
+load:0x403c9700,len:0x4
+load:0x403c9704,len:0xad0
+load:0x403cc700,len:0x29e4
+entry 0x403c9880
+ESP32-S3-CAM OV3660 启动中...
+摄像头传感器配置完成
+传感器 PID: 0x3660
+摄像头初始化成功
+连接 WiFi....
+WiFi 已连接,IP: 10.172.134.242
+Web 服务器已启动
+访问 http://10.172.134.242
+拍照成功: 49139 bytes, 分辨率: 1600x1200
+自动拍照完成
+拍照成功: 49190 bytes, 分辨率: 1600x1200
+自动拍照完成
+拍照成功: 49270 bytes, 分辨率: 1600x1200
+自动拍照完成
+ +

访问  http://10.172.134.242

+ +

浏览器打开 输入上面网址,即可看到拍的图片。

+ +

+ +

6、AI说板子是混合克隆板

+ +

前面帖子里我测试出来的SD卡的引脚是Freenove标准,但是这次摄像头的引脚测试出来又是AI-Thinker标准,很奇怪,我也问了下AI,答案是 板子是混合版。

+ +
功能模块实际使用的引脚标准说明
摄像头 (Camera)AI-ThinkerD0-D7 使用 GPIO 8-12,16-18
SD卡 (SD Card)Freenove使用 GPIO 38,39,40(与摄像头无冲突)
控制信号两者相同XCLK/SCCB/VSYNC 等使用 GPIO 4,5,6,7,13,15
+ +

AI还给出了为什么要这样设计的答案,好像很有道理,但作为硬件小白,当前我还无法判断真假,新记录下来再说。

+ +

7、为什么板子要这样设计?

+ +

这是一个聪明的 兼容性优化设计

+ +
  1. +

    摄像头用 AI-Thinker 标准:因为 OV2640/3660 摄像头模组最普及,AI-Thinker 的引脚定义是社区标准

    +
  2. +

    SD卡用 Freenove 标准:避开 AI-Thinker SD卡的引脚冲突问题(GPIO 15 与 CAM_XCLK 冲突)

    +
+ +

这样设计的好处:

+ +
  • +

    ✅ 摄像头兼容性最强(AI-Thinker 示例代码直接可用)

    +
  • +

    ✅ SD卡不占用摄像头引脚(GPIO 38-40 专用)

    +
  • +

    ✅ 可以同时使用摄像头 + SD卡 无冲突

    +
+ +
+
+
+ +
+

您可能感兴趣的与本文相关的镜像

+
+
+
+ CAM++一个可以将说话人语音识别的系统 构建by科哥 +
+
+
+

CAM++一个可以将说话人语音识别的系统 构建by科哥

+
+
AI应用
+
语音识别
+
PyTorch
+
+
+

CAM++一个可以将说话人语音识别的系统 构建by科哥

+
+
+
+ +
+
+
+ + + + +
+ +
+
+ +
+ +
+
+
+
+
+ + + +
+
+
+ + + + +
+ + + +
+ +
+ + + + + + + + + + + + + + + +
+ + +
+ + + + +
+
+ +
+ +
+ +
+ +
+
+
+
+
+
+
+ 评论 +
+
+ +
+
+ + + +
+
+ +
+ +
+
+ 成就一亿技术人! +
+
+ 拼手气红包6.0元 +
+
+
+
+
+ 还能输入1000个字符 +
+
+   +
+
+
+ 红包 + 添加红包 +
+
+ 表情包 + 插入表情 +
+
+
+
+
+ 表情包 + 代码片 +
+ +
+
+
+ + + + + + + +
+
+
+
+
+
+
+
+
+  条评论被折叠 查看 +
+
+ +
+
+
+
+
被折叠的  条评论 + 为什么被折叠? + + 到【灌水乐园】发言 +
+
+ +
+
+
+
+
+ 添加红包 + +
+
+
+ +
+ + +
+

请填写红包祝福语或标题

+
+
+ +
+ + +
+

红包个数最小为10个

+
+
+ +
+ + +
+

红包金额最低5元

+
+
+ +
+ 当前余额3.43元 + 前往充值 > +
+
+
+
+ 需支付:10.00元 +
+ + +
+
+
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+ +
成就一亿技术人!
+
+
+
+
+
+
+ 领取后你会自动成为博主和红包主的粉丝 + 规则 +
+
+
+
+
+
+ + + +
+
hope_wisdom
发出的红包 +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
实付
+
使用余额支付
+
+
+
+ + 点击重新获取 +
+
+
扫码支付
+
+
+ + + + + + +
+
+ + 钱包余额 + 0 +
+ +
+
+

抵扣说明:

+

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

+
+
+
+
+ 余额充值 +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/新手零门槛搭建 ESP32 + OV3660 摄像头 MJPEG 流服务器(ESP-IDF v5.1.6 适配版)_esp32+ov3660-CSDN博客.html b/docs/新手零门槛搭建 ESP32 + OV3660 摄像头 MJPEG 流服务器(ESP-IDF v5.1.6 适配版)_esp32+ov3660-CSDN博客.html new file mode 100644 index 0000000..83305c2 --- /dev/null +++ b/docs/新手零门槛搭建 ESP32 + OV3660 摄像头 MJPEG 流服务器(ESP-IDF v5.1.6 适配版)_esp32+ov3660-CSDN博客.html @@ -0,0 +1,3182 @@ + + + + + + + + + + + + + + + + + 新手零门槛搭建 ESP32 + OV3660 摄像头 MJPEG 流服务器(ESP-IDF v5.1.6 适配版)_esp32+ov3660-CSDN博客 + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + + + +
+
+ +
+ +
+
+
+
+

新手零门槛搭建 ESP32 + OV3660 摄像头 MJPEG 流服务器(ESP-IDF v5.1.6 适配版)

+
+ +
+
+
+ +
+
+ + +
+ + + +

前言

+

本文专为零基础新手打造,从硬件选型与接线开发环境准备代码编写与编译最终访问摄像头流,全程保姆级教程。基于 ESP-IDF v5.1.6 开发,完美适配 OV3660 摄像头与微软 Edge 浏览器,代码可直接复制使用,一次性解决「编译报错」「SOI 标记缺失」「Edge 无法解析流」等核心问题。

+

一、核心目标

+

用 ESP32-CAM 开发板 + OV3660 摄像头,搭建一个 MJPEG 视频流服务器,通过 WiFi 连接后,在浏览器中输入 ESP32 局域网 IP,即可实时查看摄像头画面。

+

二、硬件架构与选型

+

1. 核心硬件清单(新手必买,无兼容问题)

+
硬件名称规格要求作用
ESP32-CAM 开发板搭载 ESP32-WROOM-32 核心,无 PSRAM 版本即可核心控制单元,负责 WiFi 通信、摄像头驱动、HTTP 服务器运行
OV3660 摄像头模块适配 ESP32-CAM 引脚,自带排线图像采集,输出 JPEG 格式图像
ESP32-CAM 专用烧录座适配 ESP32-CAM 引脚,支持一键烧录替代 USB 转 TTL,简化烧录流程,无需手动接 IO0
USB 数据线Micro USB 接口,适配烧录座给烧录座供电,传输烧录数据
+

2. 硬件架构原理

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+

图像采集

+
+
+
+
+ + + +
+

数据传输

+
+
+
+
+ + + +
+

处理核心
JPEG 校验、WiFi 通信、HTTP 服务器

+
+
+
+
+ + + +
+

网络中转 无线连接

+
+
+
+
+ + + +
+

解析 MJPEG 流并显示画面

+
+
+
+
+
+ + + + + + +
+

OV3660 摄像头

+
+
+
+
+ + + + + +
+

ESP32-CAM 引脚

+
+
+
+
+ + + + + +
+

ESP32 核心

+
+
+
+
+ + + + + +
+

家庭 WiFi 路由器

+
+
+
+
+ + + + + +
+

浏览器 Edge/Chrome

+
+
+
+
+ + + + + +
+

显示画面

+
+
+
+
+
+
+
+
+
+

3. 硬件实物图(新手直观参考)

+

以下是 ESP32-CAM + OV3660 + 烧录座的实物图,新手可直接对照购买:
在这里插入图片描述

+

4. 硬件接线(使用烧录座,零难度)

+
(1)ESP32-CAM 与烧录座接线
+
  1. 将 ESP32-CAM 开发板金手指朝下,插入烧录座的对应插槽;
  2. 确保 ESP32-CAM 与烧录座引脚完全对齐,无偏移。
+
(2)烧录座与 USB 数据线接线
+

直接将 Micro USB 数据线插入烧录座的 Micro USB 接口,另一端插入电脑 USB 口即可(无需额外接线)。

+
(3)ESP32-CAM 与 OV3660 接线
+

直接将 OV3660 自带的排线插入 ESP32-CAM 的摄像头接口即可(注意排线方向,金手指朝向开发板正面)。

+

三、开发环境准备(ESP-IDF v5.1.6)

+
+

已安装 ESP-IDF v5.1.6 的新手可跳过此步骤,直接进入「四、项目搭建」。

+
+

1. 下载并安装 ESP-IDF Tools

+
  1. 访问 ESP 官方下载页:https://dl.espressif.com/dl/esp-idf/
  2. 下载 ESP-IDF v5.1.6 对应的 Windows 安装包(esp-idf-tools-setup-5.1.6.exe);
  3. 双击安装,一路默认下一步,记住安装路径(例如 D:\ESP32\esp-idf\v5.1.6)。
+

2. 配置 VS Code 开发环境

+
  1. 安装 VS Code,在扩展商店搜索并安装 Espressif IDF 插件(作者:Espressif Systems);
  2. 打开 VS Code,按 Ctrl+Shift+P,输入 ESP-IDF: Configure ESP-IDF Extension
  3. 选择「Use an existing ESP-IDF directory」,找到并选择刚才安装的 ESP-IDF v5.1.6 路径;
  4. 等待插件配置完成,底部状态栏显示「ESP-IDF: 5.1.6」即成功。
+

四、新手重建项目(核心步骤)

+

步骤 1:创建新项目

+
  1. 打开 VS Code,按 Ctrl+Shift+P,输入 ESP-IDF: New Project
  2. 项目名称填写 web-camera-ov3660,保存路径自定义(例如 D:\ESP32\web-camera-ov3660);
  3. 模板选择 esp32blank(空白项目),点击「Create」。
+

步骤 2:创建核心文件

+

在项目的 main 目录下,删除默认的 main.c,新建以下 6 个文件
app_main.capp_wifi.capp_wifi.happ_camera.capp_camera.happ_httpd.capp_httpd.h

+

步骤 3:复制代码(直接粘贴,无需修改)

+
+

所有代码已适配 ESP-IDF v5.1.6,解决「SOI 标记缺失」「Edge 兼容性」「编译报错」问题。

+
+
1. app_main.c(程序入口)
+
#include "esp_log.h"
+#include "nvs_flash.h"
+#include "app_wifi.h"
+#include "app_camera.h"
+#include "app_httpd.h"
+
+static const char *TAG = "MAIN";
+
+void app_main(void)
+{
+    // 1. 初始化 NVS(WiFi 依赖)
+    esp_err_t err = nvs_flash_init();
+    if (err == ESP_ERR_NVS_NO_FREE_PAGES || err == ESP_ERR_NVS_NEW_VERSION_FOUND) {
+        ESP_ERROR_CHECK(nvs_flash_erase());
+        err = nvs_flash_init();
+    }
+    ESP_ERROR_CHECK(err);
+    ESP_LOGI(TAG, "NVS 初始化成功");
+
+    // 2. 初始化 WiFi(直接调用,无返回值)
+    wifi_init_sta();
+    ESP_LOGI(TAG, "WiFi 初始化完成");
+
+    // 3. 初始化 OV3660 摄像头
+    err = app_camera_main();
+    if (err != ESP_OK) {
+        ESP_LOGE(TAG, "摄像头初始化失败!错误码:0x%x", err);
+        return;
+    }
+    ESP_LOGI(TAG, "摄像头初始化成功");
+
+    // 4. 启动 HTTP 服务器
+    httpd_handle_t server = start_webserver();
+    if (server == NULL) {
+        ESP_LOGE(TAG, "HTTP 服务器启动失败");
+        return;
+    }
+    ESP_LOGI(TAG, "系统初始化完成!访问:http://ESP32_IP/ 查看摄像头");
+}
+
+
2. app_wifi.c(WiFi 连接实现) 替换为你的 WiFi 名称和密码
+
#include <string.h>
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "freertos/event_groups.h"
+#include "esp_log.h"
+#include "esp_wifi.h"
+#include "esp_netif.h"
+#include "esp_event.h"
+#include "app_wifi.h"
+
+static const char *TAG = "WIFI";
+static EventGroupHandle_t s_wifi_event_group;
+
+// ===================== 新手修改处 =====================
+// 替换为你的 WiFi 名称和密码
+#define WIFI_SSID     "替换为你的 WiFi 名称"
+#define WIFI_PASSWORD "替换为你的 WiFi 密码"
+// =====================================================
+
+#define WIFI_CONNECTED_BIT BIT0
+#define WIFI_FAIL_BIT      BIT1
+
+static void wifi_event_handler(void* arg, esp_event_base_t event_base,
+                               int32_t event_id, void* event_data)
+{
+    if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_START) {
+        esp_wifi_connect();
+    } else if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_STA_DISCONNECTED) {
+        ESP_LOGW(TAG, "WiFi 断开,重试连接...");
+        esp_wifi_connect();
+        xEventGroupClearBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
+    } else if (event_base == IP_EVENT && event_id == IP_EVENT_STA_GOT_IP) {
+        ip_event_got_ip_t* event = (ip_event_got_ip_t*) event_data;
+        ESP_LOGI(TAG, "WiFi 连接成功,IP地址:" IPSTR, IP2STR(&event->ip_info.ip));
+        xEventGroupSetBits(s_wifi_event_group, WIFI_CONNECTED_BIT);
+    }
+}
+
+void wifi_init_sta(void)
+{
+    s_wifi_event_group = xEventGroupCreate();
+
+    // 初始化网络接口
+    ESP_ERROR_CHECK(esp_netif_init());
+    ESP_ERROR_CHECK(esp_event_loop_create_default());
+    esp_netif_create_default_wifi_sta();
+
+    // 初始化 WiFi 驱动
+    wifi_init_config_t cfg = WIFI_INIT_CONFIG_DEFAULT();
+    ESP_ERROR_CHECK(esp_wifi_init(&cfg));
+
+    // 注册事件回调
+    esp_event_handler_instance_t instance_any_id;
+    esp_event_handler_instance_t instance_got_ip;
+    ESP_ERROR_CHECK(esp_event_handler_instance_register(WIFI_EVENT,
+                                                        ESP_EVENT_ANY_ID,
+                                                        &wifi_event_handler,
+                                                        NULL,
+                                                        &instance_any_id));
+    ESP_ERROR_CHECK(esp_event_handler_instance_register(IP_EVENT,
+                                                        IP_EVENT_STA_GOT_IP,
+                                                        &wifi_event_handler,
+                                                        NULL,
+                                                        &instance_got_ip));
+
+    // 配置 WiFi 账号密码
+    wifi_config_t wifi_config = {
+        .sta = {
+            .ssid = WIFI_SSID,
+            .password = WIFI_PASSWORD,
+            .threshold.authmode = WIFI_AUTH_WPA2_PSK,
+        },
+    };
+    ESP_ERROR_CHECK(esp_wifi_set_mode(WIFI_MODE_STA));
+    ESP_ERROR_CHECK(esp_wifi_set_config(WIFI_IF_STA, &wifi_config));
+    ESP_ERROR_CHECK(esp_wifi_start());
+
+    // 等待 WiFi 连接完成
+    xEventGroupWaitBits(s_wifi_event_group,
+                        WIFI_CONNECTED_BIT | WIFI_FAIL_BIT,
+                        pdFALSE,
+                        pdFALSE,
+                        portMAX_DELAY);
+}
+
+
3. app_wifi.h(WiFi 函数声明)
+
#ifndef _APP_WIFI_H_
+#define _APP_WIFI_H_
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+void wifi_init_sta(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* _APP_WIFI_H_ */
+
+
4. app_camera.c(OV3660 摄像头驱动,解决 SOI 缺失)
+
#include "app_camera.h"
+#include "esp_camera.h"
+#include "driver/gpio.h"
+#include "esp_log.h"
+
+// ESP32-CAM 与 OV3660 引脚匹配
+#define CAM_PIN_PWDN    32
+#define CAM_PIN_RESET   -1
+#define CAM_PIN_XCLK    0
+#define CAM_PIN_SIOD    26
+#define CAM_PIN_SIOC    27
+#define CAM_PIN_D7      35
+#define CAM_PIN_D6      34
+#define CAM_PIN_D5      39
+#define CAM_PIN_D4      36
+#define CAM_PIN_D3      21
+#define CAM_PIN_D2      19
+#define CAM_PIN_D1      18
+#define CAM_PIN_D0      5
+#define CAM_PIN_VSYNC   25
+#define CAM_PIN_HREF    23
+#define CAM_PIN_PCLK    22
+
+static const char *TAG = "CAMERA";
+
+esp_err_t app_camera_main(void)
+{
+    // OV3660 专用配置(解决 SOI 标记缺失)
+    camera_config_t camera_config = {
+        .pin_pwdn = CAM_PIN_PWDN,
+        .pin_reset = CAM_PIN_RESET,
+        .pin_xclk = CAM_PIN_XCLK,
+        .pin_sccb_sda = CAM_PIN_SIOD,
+        .pin_sccb_scl = CAM_PIN_SIOC,
+        .pin_d7 = CAM_PIN_D7,
+        .pin_d6 = CAM_PIN_D6,
+        .pin_d5 = CAM_PIN_D5,
+        .pin_d4 = CAM_PIN_D4,
+        .pin_d3 = CAM_PIN_D3,
+        .pin_d2 = CAM_PIN_D2,
+        .pin_d1 = CAM_PIN_D1,
+        .pin_d0 = CAM_PIN_D0,
+        .pin_vsync = CAM_PIN_VSYNC,
+        .pin_href = CAM_PIN_HREF,
+        .pin_pclk = CAM_PIN_PCLK,
+        .xclk_freq_hz = 10000000,  // 降频到 10MHz,稳定编码
+        .ledc_timer = LEDC_TIMER_0,
+        .ledc_channel = LEDC_CHANNEL_0,
+        .pixel_format = PIXFORMAT_JPEG,  // 强制 JPEG 格式
+        .frame_size = FRAMESIZE_QVGA,    // 320x240,适配无 PSRAM
+        .jpeg_quality = 10,              // 降低质量,避免数据溢出
+        .fb_count = 2,                   // 双缓冲区,防止数据丢失
+        .fb_location = CAMERA_FB_IN_DRAM,
+        .grab_mode = CAMERA_GRAB_WHEN_EMPTY
+    };
+
+    // 初始化摄像头
+    esp_err_t err = esp_camera_init(&camera_config);
+    if (err != ESP_OK) {
+        ESP_LOGE(TAG, "摄像头初始化失败!错误码:0x%x", err);
+        return err;
+    }
+
+    // 强制锁定 JPEG 配置,杜绝 RAW 格式
+    sensor_t *s = esp_camera_sensor_get();
+    if (s) {
+        s->set_framesize(s, FRAMESIZE_QVGA);
+        s->set_pixformat(s, PIXFORMAT_JPEG);
+        ESP_LOGI(TAG, "OV3660 JPEG 配置生效");
+    } else {
+        ESP_LOGE(TAG, "获取摄像头传感器失败");
+        return ESP_FAIL;
+    }
+
+    ESP_LOGI(TAG, "摄像头初始化成功!");
+    return ESP_OK;
+}
+
+
5. app_camera.h(摄像头函数声明)
+
#ifndef APP_CAMERA_H
+#define APP_CAMERA_H
+
+#include "esp_err.h"
+
+esp_err_t app_camera_main(void);
+
+#endif  // APP_CAMERA_H
+
+
6. app_httpd.c(HTTP 服务器 + MJPEG 流,适配 Edge)
+
#include "esp_err.h"
+#include "esp_http_server.h"
+#include "esp_log.h"
+#include "esp_camera.h"
+#include "string.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "app_httpd.h"
+
+static const char *TAG = "HTTPD";
+
+// 主页 HTML(自带样式,适配浏览器)
+static const char *STREAM_HTML = 
+    "<!DOCTYPE html>"
+    "<html>"
+    "<head>"
+    "<title>ESP32 OV3660 Camera</title>"
+    "<meta charset=\"utf-8\">"
+    "<style>"
+    "body { margin: 0; padding: 20px; background-color: #f0f0f0; }"
+    ".camera-container { max-width: 640px; margin: 0 auto; background: white; padding: 10px; border-radius: 8px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }"
+    "img { width: 100%; height: auto; border-radius: 4px; }"
+    "h1 { text-align: center; color: #333; font-family: Arial, sans-serif; }"
+    "</style>"
+    "</head>"
+    "<body>"
+    "<div class=\"camera-container\">"
+    "<h1>ESP32 OV3660 摄像头画面</h1>"
+    "<img src=\"/stream\" alt=\"Camera Stream\">"
+    "</div>"
+    "</body>"
+    "</html>";
+
+static esp_err_t index_handler(httpd_req_t *req)
+{
+    httpd_resp_set_type(req, "text/html");
+    httpd_resp_send(req, STREAM_HTML, strlen(STREAM_HTML));
+    return ESP_OK;
+}
+
+// MJPEG 流核心处理(纯 ESP-IDF v5.1.6 支持 API)
+static esp_err_t stream_handler(httpd_req_t *req)
+{
+    camera_fb_t *fb = NULL;
+    esp_err_t res = ESP_OK;
+    char part_buf[128];
+    char conn_hdr[32];
+    TickType_t start_tick;
+
+    // Edge 浏览器兼容配置
+    httpd_resp_set_type(req, "multipart/x-mixed-replace; boundary=frame");
+    httpd_resp_set_hdr(req, "Cache-Control", "no-cache");
+    httpd_resp_set_hdr(req, "Connection", "close");
+
+    while (true) {
+        // 手动超时获取帧(替代高版本函数,避免编译报错)
+        start_tick = xTaskGetTickCount();
+        fb = NULL;
+        while (xTaskGetTickCount() - start_tick < 500 / portTICK_PERIOD_MS) {
+            fb = esp_camera_fb_get();
+            if (fb) break;
+            vTaskDelay(1 / portTICK_PERIOD_MS);
+        }
+
+        if (!fb) {
+            ESP_LOGE(TAG, "获取帧超时");
+            continue;
+        }
+
+        // 校验 JPEG 起始标记(0xFFD8),解决 SOI 缺失问题
+        if (fb->format == PIXFORMAT_JPEG && fb->buf[0] == 0xFF && fb->buf[1] == 0xD8) {
+            size_t hlen = snprintf(part_buf, sizeof(part_buf),
+                                  "--frame\r\n"
+                                  "Content-Type: image/jpeg\r\n"
+                                  "Content-Length: %zu\r\n\r\n",
+                                  fb->len);
+            res = httpd_resp_send_chunk(req, part_buf, hlen);
+            if (res != ESP_OK) break;
+
+            res = httpd_resp_send_chunk(req, (const char *)fb->buf, fb->len);
+            if (res != ESP_OK) break;
+        } else {
+            ESP_LOGE(TAG, "JPEG 数据异常,缺少 SOI 标记");
+        }
+
+        // 释放缓冲区,避免内存泄漏
+        if (fb) {
+            esp_camera_fb_return(fb);
+            fb = NULL;
+        }
+
+        // 检测客户端断开连接(ESP-IDF v5.1.6 标准写法)
+        ssize_t conn_hdr_len = httpd_req_get_hdr_value_len(req, "Connection");
+        if (conn_hdr_len > 0 && conn_hdr_len < sizeof(conn_hdr)) {
+            httpd_req_get_hdr_value_str(req, "Connection", conn_hdr, conn_hdr_len + 1);
+            if (strcmp(conn_hdr, "close") == 0) {
+                break;
+            }
+        }
+    }
+
+    // 清理资源
+    if (fb) esp_camera_fb_return(fb);
+    httpd_resp_send_chunk(req, "\r\n--frame--\r\n", strlen("\r\n--frame--\r\n"));
+    ESP_LOGI(TAG, "流连接断开");
+    return res;
+}
+
+httpd_handle_t start_webserver(void)
+{
+    httpd_config_t config = HTTPD_DEFAULT_CONFIG();
+    config.max_uri_handlers = 16;
+    config.server_port = 80;
+
+    httpd_handle_t server = NULL;
+    if (httpd_start(&server, &config) == ESP_OK) {
+        // 注册主页路由
+        httpd_uri_t index_uri = {
+            .uri       = "/",
+            .method    = HTTP_GET,
+            .handler   = index_handler,
+            .user_ctx  = NULL
+        };
+        httpd_register_uri_handler(server, &index_uri);
+
+        // 注册视频流路由
+        httpd_uri_t stream_uri = {
+            .uri       = "/stream",
+            .method    = HTTP_GET,
+            .handler   = stream_handler,
+            .user_ctx  = NULL
+        };
+        httpd_register_uri_handler(server, &stream_uri);
+
+        ESP_LOGI(TAG, "HTTP 服务器启动成功,端口:80");
+    } else {
+        ESP_LOGE(TAG, "HTTP 服务器启动失败");
+    }
+
+    return server;
+}
+
+void stop_webserver(httpd_handle_t server)
+{
+    if (server) {
+        httpd_stop(server);
+        ESP_LOGI(TAG, "HTTP 服务器已停止");
+    }
+}
+
+
7. app_httpd.h(HTTP 函数声明)
+
#ifndef APP_HTTPD_H
+#define APP_HTTPD_H
+
+#include "esp_http_server.h"
+
+httpd_handle_t start_webserver(void);
+void stop_webserver(httpd_handle_t server);
+
+#endif  // APP_HTTPD_H
+
+

步骤 4:修改 CMakeLists.txt(编译配置)

+

打开 main 目录下的 CMakeLists.txt,替换为以下内容:

+
idf_component_register(SRCS "app_main.c" "app_wifi.c" "app_camera.c" "app_httpd.c"
+                    INCLUDE_DIRS ".")
+
+

五、编译与烧录(使用烧录座,新手零门槛)

+

步骤 1:配置串口与目标芯片

+
  1. Ctrl+Shift+P,输入 ESP-IDF: Select Port,选择烧录座对应的串口(例如 COM3);
  2. 输入 ESP-IDF: Select Target,选择 esp32
+

步骤 2:清理并编译

+

在 VS Code 终端中执行以下命令(复制粘贴即可):

+
# 彻底清理旧缓存(解决编译残留问题)
+idf.py fullclean
+
+# 编译项目
+idf.py build
+
+
+

编译成功会显示「Project build complete」,无任何报错。

+
+

步骤 3:烧录程序(烧录座专用)

+
  1. 将 ESP32-CAM 插入烧录座,确保引脚对齐;
  2. 用 USB 数据线连接烧录座与电脑,在终端执行:
+
idf.py flash
+
+
  1. 烧录完成后,按一下 ESP32-CAM 的复位键(RST),无需手动切换 IO0。
+

步骤 4:查看串口日志(获取 ESP32 IP)

+

在终端执行:

+
idf.py monitor
+
+

等待日志输出,找到以下关键信息:

+
I (5471) WIFI: WiFi 连接成功,IP地址:192.168.31.179
+I (5941) MAIN: 系统初始化完成!访问:http://ESP32_IP/ 查看摄像头
+
+
+

记录下你的 ESP32 IP(例如 192.168.31.179)。

+
+

六、查看摄像头画面(最终验证)

+
  1. 确保电脑/手机与 ESP32 连接同一个 WiFi
  2. 打开微软 Edge/谷歌 Chrome 浏览器,输入刚才记录的 IP(例如 http://192.168.31.179);
  3. Ctrl+F5 强制刷新(避免浏览器缓存),即可看到实时摄像头画面!
  4. 画面如下
    在这里插入图片描述
+

七、新手常见问题排查

+
问题现象原因解决方法
编译报错「unknown type name」头文件缺失/顺序错误严格按本文代码的头文件顺序引入
日志显示「NO-SOI」OV3660 编码异常检查 app_camera.c 中 xclk_freq_hz 是否为 10MHz,fb_count 是否为 2
Edge 只显示标题,不显示画面浏览器缓存/流格式不兼容按 Ctrl+F5 强制刷新,关闭 Edge 安全增强模式
WiFi 连接失败账号密码错误/信号差检查 app_wifi.c 中的 WIFI_SSID 和 WIFI_PASSWORD,靠近路由器
烧录失败烧录座接触不良/串口错误重新插拔 ESP32-CAM,重新选择正确串口
+

结语

+

本文从硬件到软件,全程为新手打造,代码经过实测验证,完美适配 ESP-IDF v5.1.6、OV3660 摄像头与 Edge 浏览器。新手只需按步骤复制代码、接线、编译,即可快速搭建属于自己的 ESP32 摄像头流服务器,后续可在此基础上扩展人脸识别、视频录制等功能。

+

源码

+

已上传Gitee
https://gitee.com/yunjingshan/esp32-ov3660-mjpeg-server

+
+ + +
+
+ + + + + +
+
+ + + + +
+ +
+
+ +
+ +
+
+ +
+
+
+ + + +
+
+
+ + + + +
+ + + +
+ +
+ + + + + + + + + + + + + + + + +
+ + +
+ + + + +
+
+ +
+ +
+ +
+ +
+
+
+
+
+
+
+
评论 1
+
+
+ +
+
+ + + +
+
+ +
+ +
+
+ 成就一亿技术人! +
+
+ 拼手气红包6.0元 +
+
+
+
+
+ 还能输入1000个字符 +
+
+   +
+
+
+ 红包 + 添加红包 +
+
+ 表情包 + 插入表情 +
+
+
+
+
+ 表情包 + 代码片 +
+ +
+
+
+ + + + + + + +
+
+
+
+
+
+
+
+ +
+  条评论被折叠 查看 +
+
+ +
+
+
+
+
被折叠的  条评论 + 为什么被折叠? + + 到【灌水乐园】发言 +
+
+ +
+
+
+
+
+ 添加红包 + +
+
+
+ +
+ + +
+

请填写红包祝福语或标题

+
+
+ +
+ + +
+

红包个数最小为10个

+
+
+ +
+ + +
+

红包金额最低5元

+
+
+ +
+ 当前余额3.43元 + 前往充值 > +
+
+
+
+ 需支付:10.00元 +
+ + +
+
+
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+ +
成就一亿技术人!
+
+
+
+
+
+
+ 领取后你会自动成为博主和红包主的粉丝 + 规则 +
+
+
+
+
+
+ + + +
+
hope_wisdom
发出的红包 +
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
实付
+
使用余额支付
+
+
+
+ + 点击重新获取 +
+
+
扫码支付
+
+
+ + + + + + +
+
+ + 钱包余额 + 0 +
+ +
+
+

抵扣说明:

+

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

+
+
+
+
+ 余额充值 +
+
+ +
+
+ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/main/boards/bread-compact-wifi-s3cam/config.h b/main/boards/bread-compact-wifi-s3cam/config.h index 53a308c..eec6589 100644 --- a/main/boards/bread-compact-wifi-s3cam/config.h +++ b/main/boards/bread-compact-wifi-s3cam/config.h @@ -51,7 +51,10 @@ #define CAMERA_PIN_SIOD GPIO_NUM_48 // checked for CogNog V1.0 - original NUM_4 #define CAMERA_PIN_PWDN GPIO_NUM_NC #define CAMERA_PIN_RESET GPIO_NUM_NC -#define XCLK_FREQ_HZ 20000000 +// [2026-04-21 方案 B] 原 20MHz 在飞线路径上产生 DVP 数据线位错位 +// (画面彩色马赛克撕裂)。降到 10MHz 给飞线信号完整性 2x 裕度 +// 代价:sensor 帧率从 24fps 减半到 ~12fps(足够人脸追踪) +#define XCLK_FREQ_HZ 10000000 #define DISPLAY_BACKLIGHT_PIN GPIO_NUM_NC // checked #define DISPLAY_MOSI_PIN GPIO_NUM_NC // checked - original NUM_20 diff --git a/main/face_tracker.cc b/main/face_tracker.cc index c326abf..28a07dd 100644 --- a/main/face_tracker.cc +++ b/main/face_tracker.cc @@ -12,6 +12,8 @@ #include "dl_detect_define.hpp" #include "board.h" #include "esp32_camera.h" +#include "display/lvgl_display/jpg/image_to_jpeg.h" +#include #include #include @@ -20,6 +22,7 @@ #include #include #include +#include static const char* TAG = "FaceTracker"; static TaskHandle_t s_handle = nullptr; @@ -31,14 +34,17 @@ static float s_last_fps = 0.0f; // T07 完成后该弱符号被真实实现覆盖,无需改动本文件 extern "C" __attribute__((weak)) void uart_send_face(int x_offset, int y_offset); -// YUYV → RGB888 手动转换(每 4 字节 YUYV 生成 2 像素 6 字节 RGB) -// 公式(BT.601):R = Y + 1.402*(V-128); G = Y - 0.344*(U-128) - 0.714*(V-128); B = Y + 1.772*(U-128) +// YVYU → RGB888 手动转换(OV3660 FORMAT_CTRL00=0x61 实际输出 Y V Y U 序列) +// 每 4 字节 YVYU 生成 2 像素 6 字节 RGB888 +// 公式(BT.601 JFIF):R = Y + 1.402*(V-128); G = Y - 0.344*(U-128) - 0.714*(V-128); B = Y + 1.772*(U-128) +// [2026-04-21 修正] 之前按 YUYV (Y U Y V) 读取导致色彩偏绿紫,JPEG dump 测试证实 +// sensor 实际是 YVYU sequence,byte[1]=V, byte[3]=U(顺序反了) static inline void yuyv_to_rgb888_line(const uint8_t* yuyv, uint8_t* rgb, int pixels) { for (int i = 0; i < pixels; i += 2) { int y1 = yuyv[0]; - int u = yuyv[1] - 128; + int v = yuyv[1] - 128; // 修正:byte[1] = V(原本误当 U) int y2 = yuyv[2]; - int v = yuyv[3] - 128; + int u = yuyv[3] - 128; // 修正:byte[3] = U(原本误当 V) yuyv += 4; // 像素 1 int r1 = y1 + (359 * v) / 256; @@ -100,6 +106,43 @@ static void face_tracker_task(void* arg) { (unsigned)info.total_allocated_bytes); } + // [2026-04-21 诊断结论] 多格式 JPEG dump 测试确认:sensor 实际输出 YUYV packed 格式 + // - frame_YUYV.jpg 画面清晰(能看到戴眼镜人脸 + 背景),只是色彩偏绿紫 + // - frame_RGB565.jpg / UYVY / YUV422P 全是彩色马赛克 + // - 色偏原因:FORMAT_CTRL00=0x61 的 bit[3:0]=1 在 YUV 模式下是 YVYU sequence + // (实际字节序 Y V Y U,不是标准 YUYV 的 Y U Y V) + // → yuyv_to_rgb888_line 要按 YVYU 读取:byte[1]=V, byte[3]=U + // 保留 JPEG dump 用于拍照验证(先确认摄像头正常再跑人脸识别) + // [2026-04-22] sensor 切到硬件 JPEG 模式(CONFIG_CAMERA_OV3660_DVP_JPEG_1280X720_12FPS) + // sensor 内部已做完 YUV→RGB→JPEG 全流程色彩处理,输出标准 JPEG 字节流 + // 我们不再需要 image_to_jpeg 二次编码,直接把 f.data 透传即可 + { + vTaskDelay(pdMS_TO_TICKS(2000)); // JPEG 模式分辨率 1280x720,sensor 需要更长曝光稳定时间 + auto* cam = dynamic_cast(Board::GetInstance().GetCamera()); + Esp32Camera::FrameRef f; + if (cam && cam->CaptureForDetection(&f) && f.data && f.len > 0) { + const uint8_t* jpg = (const uint8_t*)f.data; + size_t jpg_len = f.len; + ESP_LOGI(TAG, "===JPEG_DUMP_BEGIN fmt=SENSOR_JPEG size=%u w=%u h=%u===", + (unsigned)jpg_len, f.width, f.height); + static const char b64[] = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + char line[128]; size_t lp = 0; + for (size_t i = 0; i < jpg_len; i += 3) { + uint32_t v = ((uint32_t)jpg[i] << 16); + if (i + 1 < jpg_len) v |= ((uint32_t)jpg[i+1] << 8); + if (i + 2 < jpg_len) v |= jpg[i+2]; + line[lp++] = b64[(v >> 18) & 0x3F]; + line[lp++] = b64[(v >> 12) & 0x3F]; + line[lp++] = (i + 1 < jpg_len) ? b64[(v >> 6) & 0x3F] : '='; + line[lp++] = (i + 2 < jpg_len) ? b64[v & 0x3F] : '='; + if (lp >= 72) { line[lp] = 0; printf("%s\n", line); lp = 0; } + } + if (lp > 0) { line[lp] = 0; printf("%s\n", line); } + ESP_LOGI(TAG, "===JPEG_DUMP_END==="); + cam->ReleaseDetectionFrame(f); + } + } + // 按 Kconfig 配置的 FPS 计算节拍 const TickType_t period = pdMS_TO_TICKS(1000 / CONFIG_XIAOZHI_FACE_TRACKING_FPS); TickType_t last_wake = xTaskGetTickCount(); diff --git a/scripts/auto_capture_jpeg.py b/scripts/auto_capture_jpeg.py new file mode 100644 index 0000000..8d078c6 --- /dev/null +++ b/scripts/auto_capture_jpeg.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +# auto_capture_jpeg.py +# 自动连接 ESP32 串口、触发复位、等待多个 JPEG dump、保存并打开所有图片 +# 支持新格式:===JPEG_DUMP_BEGIN fmt= size==== + +import serial +import base64 +import re +import sys +import time +import subprocess +from pathlib import Path + +PORT = "/dev/cu.usbmodem834401" +BAUD = 115200 +OUT_DIR = Path("/Users/rdzleo/Desktop/CogletESP-camera-version/scripts") +TIMEOUT_SEC = 90 +MAX_FRAMES = 4 + +BEGIN_RE = re.compile(r"===JPEG_DUMP_BEGIN\s+(?:fmt=(\S+)\s+)?size=(\d+)===") +END_RE = re.compile(r"===JPEG_DUMP_END===") +B64_RE = re.compile(r"^[A-Za-z0-9+/=]+$") + + +def main(): + print(f"[·] 打开串口 {PORT} @ {BAUD}") + ser = serial.Serial(PORT, BAUD, timeout=1) + + print("[·] 复位 ESP32 …") + ser.dtr = False + ser.rts = True + time.sleep(0.1) + ser.rts = False + time.sleep(0.1) + ser.reset_input_buffer() + + print(f"[·] 等待 JPEG_DUMP 标记(最多 {TIMEOUT_SEC}s,期望 {MAX_FRAMES} 张)…") + start = time.time() + in_dump = False + expected_size = 0 + current_fmt = None + b64_buf = [] + saved_files = [] + + while time.time() - start < TIMEOUT_SEC and len(saved_files) < MAX_FRAMES: + raw = ser.readline() + if not raw: + continue + try: + line = raw.decode("utf-8", errors="replace").rstrip("\r\n") + except Exception: + continue + + if any(k in line for k in ["FaceTracker", "Camera", "panic", "Guru", "ov3660", "Compile time"]): + print(f" {line}") + + if not in_dump: + m = BEGIN_RE.search(line) + if m: + in_dump = True + current_fmt = m.group(1) or "unknown" + expected_size = int(m.group(2)) + b64_buf = [] + print(f"[+] JPEG_BEGIN fmt={current_fmt} size={expected_size}") + continue + + if END_RE.search(line): + in_dump = False + b64_str = "".join(b64_buf) + try: + data = base64.b64decode(b64_str) + except Exception as e: + print(f"[!] base64 decode failed: {e}") + continue + + if len(data) != expected_size: + print(f"[!] 字节数差异 got={len(data)} expected={expected_size}") + + out_path = OUT_DIR / f"frame_{current_fmt}.jpg" + out_path.write_bytes(data) + print(f"[✓] 保存 {out_path.name} ({len(data)/1024:.1f} KB)") + saved_files.append(out_path) + continue + + stripped = line.strip() + if B64_RE.match(stripped): + b64_buf.append(stripped) + + ser.close() + + if not saved_files: + print("[!] 没有抓到任何 JPEG 帧") + return 1 + + print(f"\n[✓] 共保存 {len(saved_files)} 张") + for p in saved_files: + print(f" - {p}") + # 用 Finder 打开目录,用户可以并排对比 + subprocess.run(["open", str(OUT_DIR)]) + # 或者直接打开所有 JPEG + for p in saved_files: + subprocess.run(["open", str(p)]) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/extract_jpeg_from_log.py b/scripts/extract_jpeg_from_log.py new file mode 100755 index 0000000..f7a9b85 --- /dev/null +++ b/scripts/extract_jpeg_from_log.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +# extract_jpeg_from_log.py +# 从 ESP32 串口日志中提取 base64 编码的 JPEG 图像并保存为 .jpg 文件 +# +# 用法: +# 1) 启动 monitor 并把日志重定向到文件: +# idf.py -p /dev/cu.usbmodem834401 monitor > /tmp/esp32.log +# 或者直接从已有日志提取: +# python3 extract_jpeg_from_log.py /tmp/esp32.log +# 2) 设备启动后会打印 "===JPEG_DUMP_BEGIN===" .... "===JPEG_DUMP_END===" +# 3) 运行此脚本会在当前目录生成 frame_001.jpg 等文件 +# +# 也支持从 stdin 实时读取: +# idf.py monitor | python3 extract_jpeg_from_log.py - + +import sys +import re +import base64 +from pathlib import Path + +BEGIN_RE = re.compile(r"===JPEG_DUMP_BEGIN\s+size=(\d+)===") +END_RE = re.compile(r"===JPEG_DUMP_END===") +# 匹配纯 base64 行(不含普通文本) +B64_RE = re.compile(r"^[A-Za-z0-9+/=]+$") + + +def extract(lines, out_dir=Path(".")): + frame_idx = 0 + in_dump = False + b64_buf = [] + expected_size = 0 + + for raw in lines: + line = raw.rstrip("\r\n") + # 去掉 ESP 日志时间戳前缀可能引入的干扰:只在 begin/end 标记附近处理 + if not in_dump: + m = BEGIN_RE.search(line) + if m: + in_dump = True + expected_size = int(m.group(1)) + b64_buf = [] + print(f"[+] JPEG_BEGIN size={expected_size}") + continue + + if END_RE.search(line): + in_dump = False + b64_str = "".join(b64_buf) + try: + data = base64.b64decode(b64_str) + except Exception as e: + print(f"[!] base64 decode failed: {e}") + continue + if len(data) != expected_size: + print( + f"[!] size mismatch: got {len(data)} expected {expected_size} " + f"(可能是 monitor 丢字节,仍尝试保存)" + ) + frame_idx += 1 + out_path = out_dir / f"frame_{frame_idx:03d}.jpg" + out_path.write_bytes(data) + print(f"[✓] saved {out_path} ({len(data)} bytes)") + # macOS: 自动打开 + if sys.platform == "darwin": + import subprocess + subprocess.run(["open", str(out_path)]) + continue + + # 处于 dump 区间,只收集看起来是 base64 的行 + stripped = line.strip() + if B64_RE.match(stripped): + b64_buf.append(stripped) + # 其他行(比如 "I (xxx) TAG:" 的日志)忽略 + + +def main(): + if len(sys.argv) < 2: + print("Usage: extract_jpeg_from_log.py ") + sys.exit(1) + src = sys.argv[1] + if src == "-": + extract(sys.stdin) + else: + with open(src, "r", errors="replace") as f: + extract(f) + + +if __name__ == "__main__": + main() diff --git a/设备运行日志.txt b/设备运行日志.txt new file mode 100644 index 0000000..e396af2 --- /dev/null +++ b/设备运行日志.txt @@ -0,0 +1,149 @@ +rdzleo@RdzleodeMac-Studio CogletESP-camera-version % export IDF_PATH='/Users/rdzleo/esp/esp-idf/v5.4.2/esp-idf' +rdzleo@RdzleodeMac-Studio CogletESP-camera-version % '/Users/rdzleo/.espressif/python_env/idf5.4_py3.13_env/bin/python3' '/Users/rdzleo/esp/esp-idf/v5.4.2/esp-idf/tools/idf_monitor.py' -p /dev/tty.usbmodem834401 -b 115200 -- +toolchain-prefix xtensa-esp32s3-elf- --make ''/Users/rdzleo/.espressif/python_env/idf5.4_py3.13_env/bin/python3' '/Users/rdzleo/esp/esp-idf/v5.4.2/esp-idf/tools/idf.py'' --target esp32s3 '/Users/rdzleo/Desktop/CogletESP-came +ra-version/build/xiaozhi.elf' +--- Warning: Serial ports accessed as /dev/tty.* will hang gdb if launched. +--- Using /dev/cu.usbmodem834401 instead... +--- esp-idf-monitor 1.8.0 on /dev/cu.usbmodem834401 115200 +--- Quit: Ctrl+] | Menu: Ctrl+T | Help: Ctrl+T followed by Ctrl+H +ESP-ROM:esp32s3-20210327 +Build:Mar 27 2021 +rst:0x15 (USB_UART_CHIP_RESET),boot:0x8 (SPI_FAST_FLASH_BOOT) +Saved PC:0x40384d8e +--- 0x40384d8e: esp_cpu_wait_for_intr at /Users/rdzleo/esp/esp-idf/components/esp_hw_support/cpu.c:64 +SPIWP:0xee +mode:DIO, clock div:1 +load:0x3fce2820,len:0x56c +load:0x403c8700,len:0x4 +load:0x403c8704,len:0xc30 +load:0x403cb700,len:0x2e2c +entry 0x403c890c +I (37) octal_psram: vendor id : 0x0d (AP) +I (37) octal_psram: dev id : 0x02 (generation 3) +I (37) octal_psram: density : 0x03 (64 Mbit) +I (39) octal_psram: good-die : 0x01 (Pass) +I (43) octal_psram: Latency : 0x01 (Fixed) +I (47) octal_psram: VCC : 0x01 (3V) +I (51) octal_psram: SRF : 0x01 (Fast Refresh) +I (56) octal_psram: BurstType : 0x01 (Hybrid Wrap) +I (61) octal_psram: BurstLen : 0x01 (32 Byte) +I (65) octal_psram: Readlatency : 0x02 (10 cycles@Fixed) +I (71) octal_psram: DriveStrength: 0x00 (1/1) +I (75) MSPI Timing: PSRAM timing tuning index: 4 +I (79) esp_psram: Found 8MB PSRAM device +I (83) esp_psram: Speed: 80MHz +I (86) cpu_start: Multicore app +I (100) cpu_start: Pro cpu start user code +I (100) cpu_start: cpu freq: 240000000 Hz +I (100) app_init: Application information: +I (100) app_init: Project name: xiaozhi +I (104) app_init: App version: 2.0.5 +I (108) app_init: Compile time: Apr 20 2026 18:05:09 +I (113) app_init: ELF file SHA256: cd6d6438e... +I (117) app_init: ESP-IDF: v5.4.2-390-g0f6b683441-dirty +I (123) efuse_init: Min chip rev: v0.0 +I (127) efuse_init: Max chip rev: v0.99 +I (131) efuse_init: Chip rev: v0.2 +I (135) heap_init: Initializing. RAM available for dynamic allocation: +I (141) heap_init: At 3FCAFCE8 len 00039A28 (230 KiB): RAM +I (146) heap_init: At 3FCE9710 len 00005724 (21 KiB): RAM +I (151) heap_init: At 3FCF0000 len 00008000 (32 KiB): DRAM +I (156) heap_init: At 600FE000 len 00001FD8 (7 KiB): RTCRAM +I (162) esp_psram: Adding pool of 8192K of PSRAM memory to heap allocator +I (169) spi_flash: detected chip: generic +I (172) spi_flash: flash io: qio +I (175) sleep_gpio: Configure to isolate all GPIO pins in sleep state +I (181) sleep_gpio: Enable automatic switching of GPIO sleep configuration +I (188) main_task: Started on CPU0 +I (198) esp_psram: Reserving pool of 64K of internal memory for DMA/internal allocations +I (198) main_task: Calling app_main() +I (198) uart: ESP_INTR_FLAG_IRAM flag not set while CONFIG_UART_ISR_IN_IRAM is enabled, flag updated +I (238) Board: UUID=fcb5789b-4c1b-41b1-9271-4e4b23b27178 SKU=bread-compact-wifi-s3cam +I (238) gpio: GPIO[0]| InputEn: 1| OutputEn: 0| OpenDrain: 0| Pullup: 1| Pulldown: 0| Intr:0 +I (238) button: IoT Button Version: 4.1.6 +I (238) gpio: GPIO[39]| InputEn: 0| OutputEn: 0| OpenDrain: 0| Pullup: 1| Pulldown: 0| Intr:0 +I (248) gpio: GPIO[40]| InputEn: 0| OutputEn: 0| OpenDrain: 0| Pullup: 1| Pulldown: 0| Intr:0 +I (258) gpio: GPIO[41]| InputEn: 0| OutputEn: 0| OpenDrain: 0| Pullup: 1| Pulldown: 0| Intr:0 +I (268) gpio: GPIO[42]| InputEn: 0| OutputEn: 0| OpenDrain: 0| Pullup: 1| Pulldown: 0| Intr:0 +I (298) ov3660: Detected Camera sensor PID=0x3660 +I (428) Esp32Camera: Camera init success +E (428) CAM: camera ptr: 0x3fcca0b4 +I (428) Application: STATE: starting +I (428) NoAudioCodec: Simplex channels created +I (428) AudioCodec: Set input enable to true +I (428) AudioCodec: Set output enable to true +I (438) AudioCodec: Audio codec started +I (438) pp: pp rom version: e7ae62f +I (438) net80211: net80211 rom version: e7ae62f +I (458) wifi:wifi driver task: 3fcdcaf4, prio:23, stack:6144, core=0 +I (458) wifi:wifi firmware version: 3263cda +I (458) wifi:wifi certification version: v7.0 +I (458) wifi:config NVS flash: disabled +I (468) wifi:config nano formatting: enabled +I (468) wifi:Init data frame dynamic rx buffer num: 6 +I (468) wifi:Init dynamic rx mgmt buffer num: 5 +I (478) wifi:Init management short buffer num: 32 +I (478) wifi:Init dynamic tx buffer num: 32 +I (488) wifi:Init static tx FG buffer num: 2 +I (488) wifi:Init static rx buffer size: 1600 +I (498) wifi:Init static rx buffer num: 3 +I (498) wifi:Init dynamic rx buffer num: 6 +I (498) wifi_init: rx ba win: 3 +I (508) wifi_init: accept mbox: 6 +I (508) wifi_init: tcpip mbox: 16 +I (508) wifi_init: udp mbox: 6 +I (508) wifi_init: tcp mbox: 6 +I (518) wifi_init: tcp tx win: 5760 +I (518) wifi_init: tcp rx win: 5760 +I (518) wifi_init: tcp mss: 1440 +I (528) phy_init: phy_version 701,f4f1da3a,Mar 3 2025,15:50:10 +I (568) phy_init: Saving new calibration data due to checksum failure or outdated calibration data, mode(0) +I (618) wifi:mode : sta (20:6e:f1:b9:9a:28) +I (618) wifi:enable tsf +I (3028) WifiStation: Found AP: airhub, BSSID: 70:2a:d7:85:bc:eb, RSSI: -35, Channel: 1, Authmode: 3 +W (3028) wifi:Password length matches WPA2 standards, authmode threshold changes from OPEN to WPA2 +I (3128) wifi:new:<1,0>, old:<1,0>, ap:<255,255>, sta:<1,0>, prof:1, snd_ch_cfg:0x0 +I (3128) wifi:state: init -> auth (0xb0) +I (3128) wifi:state: auth -> assoc (0x0) +I (3138) wifi:state: assoc -> run (0x10) +I (3168) wifi:connected with airhub, aid = 1, channel 1, BW20, bssid = 70:2a:d7:85:bc:eb +I (3168) wifi:security: WPA2-PSK, phy: bgn, rssi: -34 +I (3168) wifi:pm start, type: 1 + +I (3178) wifi:dp: 1, bi: 102400, li: 3, scale listen interval from 307200 us to 307200 us +I (3188) wifi:set rx beacon pti, rx_bcn_pti: 0, bcn_timeout: 25000, mt_pti: 0, mt_time: 10000 +I (3198) wifi:idx:0 (ifx:0, 70:2a:d7:85:bc:eb), tid:0, ssn:0, winSize:64 +I (3228) wifi:AP's beacon interval = 102400 us, DTIM period = 1 +I (5758) esp_netif_handlers: sta ip: 192.168.124.53, mask: 255.255.255.0, gw: 192.168.124.1 +I (5758) WifiStation: Got IP: 192.168.124.53 +I (5758) Assets: The storage free size is 20224 KB +I (5758) Assets: The partition size is 6016 KB +I (5828) Assets: The checksum calculation time is 67 ms +create static modelsI (5828) MODEL_LOADER: Successfully load srmodels +I (5838) Assets: Refreshing display theme... +W (5838) Display: SetEmotion: microchip_ai +I (5838) Application: STATE: activating +W (5838) Display: SetStatus: 检查新版本... +I (5848) Ota: Current version: 2.0.5 +I (6488) esp-x509-crt-bundle: Certificate validated +I (6918) HttpClient: Established new connection to api.tenclass.net:443 +E (7238) Dynamic Impl: mbedtls_ssl_fetch_input error=29312 +I (7238) HttpClient: HTTP connection closed +I (7238) Ota: Current is the latest version +I (7238) Ota: Running partition: ota_0 +W (7248) Display: SetStatus: 登录服务器... +I (7248) MCP: Add tool: self.get_device_status +I (7248) MCP: Add tool: self.audio_speaker.set_volume +I (7258) MCP: Add tool: self.camera.take_photo +I (7258) MCP: Add tool: self.get_system_info [user] +I (7268) MCP: Add tool: self.reboot [user] +I (7268) MCP: Add tool: self.upgrade_firmware [user] +I (7278) MCP: Add tool: self.assets.set_download_url [user] +I (7278) MQTT: Connecting to endpoint mqtt.xiaozhi.me +I (7388) esp-x509-crt-bundle: Certificate validated +I (8048) MQTT: Connected to endpoint +I (15438) AudioCodec: Set input enable to false +I (15438) AudioCodec: Set output enable to false +I (15438) SystemInfo: free sram: 155443 minimal sram: 152567 +I (25438) SystemInfo: free sram: 155407 minimal sram: 152567 +I (35438) SystemInfo: free sram: 155443 minimal sram: 152567 \ No newline at end of file