零知开源——ESP32驱动OV7670摄像头实现简易照相机系统

该文章已生成可运行项目,

 ✔零知IDE 是一个真正属于国人自己的开源软件平台,在开发效率上超越了Arduino平台并且更加容易上手,大大降低了开发难度。零知开源在软件方面提供了完整的学习教程和丰富示例代码,让不懂程序的工程师也能非常轻而易举的搭建电路来创作产品,测试产品。快来动手试试吧!

✔访问零知实验室,获取更多实战项目和教程资源吧!

www.lingzhilab.com

目录

一、硬件接线部分

1.1 硬件清单

1.2 接线方案

1.3 具体接线图

1.4 连接实物图

二、代码解释部分

2.1 核心代码结构

2.2 摄像头初始化

2.3 WebSocket图像传输

2.4 图像数据采集与处理

2.5 完整代码

三、OV7670摄像头模块工作原理

3.1 寄存器配置

3.2 输出图像数据时序

四、项目结果演示

4.1 零知IDE操作

4.2 效果展示

4.3 演示视频

五、常见问题解答

Q1:视频流卡顿严重怎么办?

Q2:拍摄的照片色彩失真怎么办?

Q3:如何提高图像质量?


(1)项目概述

        本项目基于零知ESP32开发板和OV7670摄像头模块,实现了一个功能完整的简易照相机系统。系统采用QQVGA(160×120)分辨率,RGB565色彩格式,在保证图像质量的同时控制数据传输量,确保ESP32能够稳定处理。通过优化的WebSocket传输协议,实现了在网页端实时显示摄像头画面和拍照功能。

(2)项目难点及解决方案

       问题描述1:ESP32 WROOM内存有限

解决方案:采用预分配内存策略,分配好图像传输缓冲区,避免运行时动态内存分配

       问题描述2:WebSocket实时传输视频流需要高效的数据压缩

解决方案:将每帧图像分成多行传输,减少单次数据传输量

一、硬件接线部分

1.1 硬件清单

组件名称规格型号数量备注
主控板零知ESP32 WROOM1核心处理单元
摄像头模块OV7670130万像素,支持RGB565输出
连接线杜邦线若干用于各模块间连接
电源USB数据线15V供电
电阻10kΩ2用于I2C上拉

1.2 接线方案

        模块使用3.3V供电,OV7670摄像头与ESP32的连接按照以下的引脚定义进行:

const camera_config_t cam_conf = {
  .D0 = 36,    // 数据位0
  .D1 = 39,    // 数据位1
  .D2 = 34,    // 数据位2
  .D3 = 35,    // 数据位3
  .D4 = 32,    // 数据位4
  .D5 = 33,    // 数据位5
  .D6 = 25,    // 数据位6
  .D7 = 26,    // 数据位7
  .XCLK = 15,  // 时钟信号
  .PCLK = 14,  // 像素时钟
  .VSYNC = 13, // 垂直同步信号
  .xclk_freq_hz = 10000000,  // 10MHz时钟
  .ledc_timer = LEDC_TIMER_0,
  .ledc_channel = LEDC_CHANNEL_0  
};

        注意:SCCB通信还需要连接I2C总线,SDA接ESP32的GPIO21、SCL接GPIO22。RET复位接3.3V,PWDN接GND

1.3 具体接线图

        ps:模块上的LDO可以将3.3V转换为2.8V和1.8V供摄像头使用

1.4 连接实物图

二、代码解释部分

2.1 核心代码结构

        ①摄像头初始化模块:配置OV7670寄存器和工作参数

        ②网络连接模块:处理WiFi连接和Web服务器启动、

        ③WebSocket传输模块:实现实时视频流数据传输

        ④网页服务模块:提供用户交互界面

        ⑤图像处理模块:处理图像数据的采集和转换

2.2 摄像头初始化

// 摄像头配置结构体
const camera_config_t cam_conf = {
  .D0 = 36,    // 数据位0
  .D1 = 39,    // 数据位1
  // ... 其他引脚配置
  .xclk_freq_hz = 10000000,  // 10MHz时钟
  .ledc_timer = LEDC_TIMER_0,
  .ledc_channel = LEDC_CHANNEL_0  
};

// 摄像头初始化
esp_err_t err = cam.init(&cam_conf, CAM_RES, RGB565);
if(err != ESP_OK){
  Serial.println(F("cam.init ERROR"));
  while(1);  // 初始化失败时停止程序
}

        cam_conf:摄像头配置结构体,包含引脚定义和时钟配置
        CAM_RES:分辨率设置,本项目使用QQVGA(160x120)
        RGB565:色彩格式,每个像素占用2字节

2.3 WebSocket图像传输

// 初始化图像传输头部
bool setImgHeader(uint16_t w, uint16_t h){
  line_h = h;
  line_size = w * 2;  // RGB565格式,每个像素2字节
  data_size = 2 + line_size * h;  // 行号(2字节) + 图像数据
  
  // 分配内存
  WScamData = (uint8_t*)malloc(data_size + 4);  // +4字节WebSocket头部
  
  // 设置WebSocket帧头部
  WScamData[0] = OP_BIN;  // 二进制数据
  WScamData[1] = 126;     // 数据长度标识
  WScamData[2] = (uint8_t)(data_size / 256);  // 数据长度高字节
  WScamData[3] = (uint8_t)(data_size % 256);  // 数据长度低字节
  return true;  
}

// 发送图像数据
void WS_sendImg(uint16_t lineNo){
  // 设置行号
  WScamData[4] = (uint8_t)(lineNo % 256);
  WScamData[5] = (uint8_t)(lineNo / 256);
  
  // 发送数据
  uint16_t len = data_size + 4;
  uint8_t *pData = WScamData;
  while(len){
    uint16_t send_size = (len > UNIT_SIZE) ? UNIT_SIZE : len;
    WSclient.write(pData, send_size);
    len -= send_size;
    pData += send_size;
  }
}

        WebSocket二进制数据传输协议
        分块传输机制,避免大数据包传输问题

2.4 图像数据采集与处理

       ① 将RGB565数据转换为适合网络传输的格式
       ② 通过WebSocket发送数据到网页端

        ③ 网页端JavaScript将数据转换为图像显示

// 主循环中的图像采集
void loop(void){
  uint16_t y, dy;
  dy = CAM_HEIGHT / CAM_DIV;  // 每次处理的行数
  
  while(1){
    for(y = 0; y < CAM_HEIGHT; y += dy){      
      // 获取dy行图像数据
      cam.getLines(y+1, &WScamData[6], dy);
      
      if(WS_on && !snapshotInProgress){
        if(WSclient){
          WS_sendImg(y);  // 发送图像数据
        }
      }
    }
    
    if(!WS_on){
      Ini_HTTP_Response();  // 处理HTTP请求
    }
  }
}

2.5 完整代码

//*************************************************************************
// OV7670 (non FIFO) Simple Web streamer for ESP32 
// Optimized version for QQVGA (160x120) resolution
// Added snapshot functionality with dropdown menu
//*************************************************************************
#include <Wire.h>
#include <SPI.h>
#include "freertos/FreeRTOS.h"
#include "freertos/task.h"
#include "freertos/semphr.h"
#include "esp_log.h"
#include <WiFi.h>
#include <WiFiMulti.h>
#include "hwcrypto/sha.h"
#include "base64.h"
#include <OV7670.h>

// Network configuration
IPAddress myIP = IPAddress(192, 168, 3, 78);    // Static IP address
IPAddress myGateway = IPAddress(192, 168, 3, 1);

// Camera pin configuration
const camera_config_t cam_conf = {
  .D0 = 36,
  .D1 = 39,
  .D2 = 34,
  .D3 = 35,
  .D4 = 32,
  .D5 = 33,
  .D6 = 25,
  .D7 = 26,
  .XCLK = 15,
  .PCLK = 14,
  .VSYNC = 13,
  .xclk_freq_hz = 10000000,      // XCLK 10MHz
  .ledc_timer = LEDC_TIMER_0,
  .ledc_channel = LEDC_CHANNEL_0  
};
// SSCB_SDA(SIOD) --> 21(ESP32)
// SSCB_SCL(SIOC) --> 22(ESP32)
// RESET   --> 3.3V
// PWDN    --> GND
// HREF    --> NC

// Camera resolution settings for QQVGA
#define CAM_RES     QQVGA     // Camera resolution
#define CAM_WIDTH   160       // Image width
#define CAM_HEIGHT  120       // Image height
#define CAM_DIV     1         // Number of divisions per frame

OV7670 cam;                   // Camera object
WiFiServer server(80);        // Web server on port 80
WiFiClient WSclient;          // WebSocket client
boolean WS_on = false;        // WebSocket connection flag
WiFiMulti wifiMulti;          // WiFi multi-connection manager

// HTML and JavaScript content for the web page
const char *html_head = "HTTP/1.1 200 OK\r\n"
  "Content-type:text/html\r\n"
  "Connection:close\r\n"    
  "\r\n"    // Empty line
  "<!DOCTYPE html>\n"
  "<html lang='ja'>\n"
  "<head>\n"
  "<meta charset='UTF-8'>\n"
  "<meta name='viewport' content='width=device-width'>\n"
  "<title>OV7670 实时摄像头</title>\n"
  "<style>\n"
  "body { font-family: Arial, sans-serif; margin: 20px; background: #f5f5f5; }\n"
  ".container { max-width: 800px; margin: 0 auto; background: white; padding: 20px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }\n"
  "h1 { color: #333; text-align: center; margin-bottom: 20px; }\n"
  "#msg { font-size: 18px; color: #FF0000; text-align: center; margin: 10px 0; }\n"
  "#msgIn { font-size: 16px; color: #007BFF; text-align: center; margin: 10px 0; }\n"
  ".controls { display: flex; justify-content: center; gap: 10px; margin: 15px 0; flex-wrap: wrap; }\n"
  "button { padding: 10px 15px; font-size: 14px; border: none; border-radius: 4px; cursor: pointer; transition: background 0.3s; }\n"
  ".btn-primary { background: #007BFF; color: white; }\n"
  ".btn-primary:hover { background: #0056b3; }\n"
  ".btn-danger { background: #dc3545; color: white; }\n"
  ".btn-danger:hover { background: #c82333; }\n"
  ".btn-success { background: #28a745; color: white; }\n"
  ".btn-success:hover { background: #218838; }\n"
  ".video-container { text-align: center; margin: 15px 0; padding: 10px; background: #f8f9fa; border-radius: 4px; }\n"
  ".snapshot-management { margin: 15px 0; text-align: center; }\n"
  "select { padding: 8px; border: 1px solid #ddd; border-radius: 4px; margin-right: 10px; }\n"
  ".snapshot-display { text-align: center; margin-top: 15px; }\n"
  "#currentSnapshot { max-width: 100%; border: 2px solid #ddd; border-radius: 4px; }\n"
  "</style>\n"
  "</head>\n"
  "<body>\n"
  "<div class='container'>\n"
  "<h1>ESP32 OV7670 照相机</h1>\n";
  
const char *html_body =  
  "<div id='msg'>WebSocket 正在连接...</div>\n"
  "<div id='msgIn'>0 fps</div>\n"
  "<div class='controls'>\n"
  "<button class='btn-primary' onclick='takeSnapshot()'>点击拍照</button>\n"
  "<button class='btn-success' onclick='saveSnapshot()'>保存当前照片</button>\n"
  "</div>\n"
  "<div class='video-container'>\n"
  "<canvas id='cam_canvas' width='160' height='120'></canvas>\n"
  "</div>\n"
  "<div class='snapshot-management'>\n"
  "<select id='snapshotSelector' onchange='showSelectedSnapshot()'>\n"
  "<option value=''>-- 选择照片 --</option>\n"
  "</select>\n"
  "<button class='btn-danger' onclick='deleteSelectedSnapshot()'>删除所选照片</button>\n"
  "</div>\n"
  "<div class='snapshot-display'>\n"
  "<img id='currentSnapshot' src='' alt='选中的照片将显示在这里'>\n"
  "</div>\n"
  "<script language='javascript' type='text/javascript'>\n"
  "var wsUri = 'ws://";

// 完整的JavaScript代码作为一个字符串常量
const char *html_script =  
  "var socket = null;\n"
  "var tms;\n"    
  "var msgIn;\n"
  "var msg;\n"    
  "var ctx;\n"
  "var width;\n"
  "var height;\n"  
  "var imageData;\n"    
  "var pixels;\n"  
  "var fps = 0;\n"
  "var snapshotMode = false;\n"
  "var snapshotData = null;\n"
  "var snapshotIndex = 0;\n"
  "var snapshots = {};\n"  // 存储所有快照的对象,键为时间戳,值为DataURL
  "var currentSnapshotCanvas = null;\n"  // 当前显示的快照canvas
          
  "window.onload = function(){\n"
  " msgIn = document.getElementById('msgIn');\n"
  " msg = document.getElementById('msg');\n"
  " var c = document.getElementById('cam_canvas');\n"
  " ctx = c.getContext('2d');\n"
  " width = c.width;\n"
  " height = c.height;\n"   
  " imageData = ctx.createImageData( width, 1 );\n"
  " pixels = imageData.data;\n"   
  " setTimeout('ws_connect()', 1000);\n"
  "}\n"
    
  "function Msg(message){ msg.innerHTML = message;}\n"            
  
  "function ws_connect(){\n"  
  " tms = new Date();\n"    
  " if(socket == null){\n"
  "  socket = new WebSocket(wsUri);\n"
  "  socket.binaryType = 'arraybuffer';\n"    
  "  socket.onopen   = function(evt){ onOpen(evt) };\n"
  "  socket.onclose   = function(evt){ onClose(evt) };\n"
  "  socket.onmessage = function(evt){ onMessage(evt) };\n"
  "  socket.onerror   = function(evt){ onError(evt) };\n"
  " }\n"
  " setTimeout('fpsShow()', 1000);\n"  
  "}\n"
    
  "function onOpen(evt){ Msg('已连接');}\n"
  "function onClose(evt){ Msg('WS.Close.DisConnected ' + evt.code +':'+ evt.reason); WS_close();}\n"
  "function onError(evt){ Msg(evt.data);}\n"

  "function onMessage(evt){\n"
  " var data = evt.data;\n"
  " if( typeof data == 'string'){\n"
  "  if(data.startsWith('SNAPSHOT:')) {\n"
  "   handleSnapshotData(data.substring(9));\n"
  "  } else {\n"
  "   msgIn.innerHTML = data;\n"
  "  }\n"
  " }else if( data instanceof ArrayBuffer){\n"
  "  if(snapshotMode) {\n"
  "   handleSnapshotBinary(data);\n"
  "  } else {\n"
  "   drawLine(data);\n"
  "  }\n"
  " }else if( data instanceof Blob){\n"
  "  Msg('Blob data received');\n"     
  " }\n"
  "}\n"    

  "function WS_close(){\n"
  " socket.close();\n"
  " socket = null;\n"
  " setTimeout('ws_connect()', 1);\n"  // Try to reconnect after 1ms
  "}\n"
  
  "function fpsShow(){\n"    // Display frames per second
  " msgIn.innerHTML = String(fps)+'fps';\n"
  " fps = 0;\n"
  " setTimeout('fpsShow()', 1000);\n"
  "}\n"
  
  "function drawLine(data){\n"
  " var buf = new Uint16Array(data);\n"  
  " var lineNo = buf[0];\n"
  " for(var y = 0; y < (buf.length-1)/width; y+=1){\n"     
  "  var base = 0;\n"
  "  for(var x = 0; x < width; x += 1){\n"
  "   var c = 1 + x + y * width;\n"     
  "   pixels[base+0] = (buf[c] & 0xf800) >> 8 | (buf[c] & 0xe000) >> 13;\n"  // Red
  "   pixels[base+1] = (buf[c] & 0x07e0) >> 3 | (buf[c] & 0x0600) >> 9;\n"   // Green
  "   pixels[base+2] = (buf[c] & 0x001f) << 3 | (buf[c] & 0x001c) >> 2;\n"   // Blue
  "   pixels[base+3] = 255;\n"  // Alpha
  "   base += 4;\n"
  "  }\n"
  "  ctx.putImageData(imageData, 0, lineNo + y);\n"
  " }\n"
  " if(lineNo + y == height) fps+=1;\n"  
  "}\n"
  
  "function takeSnapshot() {\n"
  " if(socket && socket.readyState === WebSocket.OPEN) {\n"
  "  snapshotMode = true;\n"
  "  snapshotData = new Uint16Array(19200);\n"  // 160*120 = 19200
  "  snapshotIndex = 0;\n"
  "  socket.send('SNAPSHOT');\n"
  "  Msg('正在拍照...');\n"
  " }\n"
  "}\n"
  
  "function saveSnapshot() {\n"
  " if(currentSnapshotCanvas) {\n"
  "   var timestamp = new Date().toLocaleString();\n"
  "   var dataURL = currentSnapshotCanvas.toDataURL();\n"
  "   snapshots[timestamp] = dataURL;\n"
  "   \n"
  "   // 更新下拉菜单\n"
  "   var selector = document.getElementById('snapshotSelector');\n"
  "   var option = document.createElement('option');\n"
  "   option.value = timestamp;\n"
  "   option.textContent = timestamp;\n"
  "   selector.appendChild(option);\n"
  "   \n"
  "   // 自动选择新添加的快照\n"
  "   selector.value = timestamp;\n"
  "   showSelectedSnapshot();\n"
  "   \n"
  "   Msg('照片已保存: ' + timestamp);\n"
  " } else {\n"
  "   Msg('没有可保存的照片');\n"
  " }\n"
  "}\n"
  
  "function showSelectedSnapshot() {\n"
  " var selector = document.getElementById('snapshotSelector');\n"
  " var selectedValue = selector.value;\n"
  " var imgElement = document.getElementById('currentSnapshot');\n"
  " \n"
  " if(selectedValue && snapshots[selectedValue]) {\n"
  "   imgElement.src = snapshots[selectedValue];\n"
  "   imgElement.style.display = 'block';\n"
  "   Msg('Showing snapshot: ' + selectedValue);\n"
  " } else {\n"
  "   imgElement.src = '';\n"
  "   imgElement.style.display = 'none';\n"
  "   Msg('No snapshot selected');\n"
  " }\n"
  "}\n"
  
  "function deleteSelectedSnapshot() {\n"
  " var selector = document.getElementById('snapshotSelector');\n"
  " var selectedValue = selector.value;\n"
  " \n"
  " if(selectedValue && snapshots[selectedValue]) {\n"
  "   // 从对象中删除\n"
  "   delete snapshots[selectedValue];\n"
  "   \n"
  "   // 从下拉菜单中删除\n"
  "   for(var i = 0; i < selector.options.length; i++) {\n"
  "     if(selector.options[i].value === selectedValue) {\n"
  "       selector.remove(i);\n"
  "       break;\n"
  "     }\n"
  "   }\n"
  "   \n"
  "   // 清空显示\n"
  "   var imgElement = document.getElementById('currentSnapshot');\n"
  "   imgElement.src = '';\n"
  "   imgElement.style.display = 'none';\n"
  "   \n"
  "   // 重置选择器\n"
  "   selector.value = '';\n"
  "   \n"
  "   Msg('选中照片已删除: ' + selectedValue);\n"
  " } else {\n"
  "   Msg('请选择要删除的照片');\n"
  " }\n"
  "}\n"
  
  "function handleSnapshotBinary(data) {\n"
  " var buf = new Uint16Array(data);\n"
  " var lineNo = buf[0];\n"
  " \n"
  " for(var i = 1; i < buf.length; i++) {\n"
  "  if(snapshotIndex < snapshotData.length) {\n"
  "   snapshotData[snapshotIndex++] = buf[i];\n"
  "  }\n"
  " }\n"
  " \n"
  " if(snapshotIndex >= snapshotData.length) {\n"
  "  completeSnapshot();\n"
  " }\n"
  "}\n"
  
  "function completeSnapshot() {\n"
  " snapshotMode = false;\n"
  " \n"
  " // Create a new canvas for the snapshot\n"
  " var canvas = document.createElement('canvas');\n"
  " canvas.width = width;\n"
  " canvas.height = height;\n"
  " \n"
  " var snapCtx = canvas.getContext('2d');\n"
  " var snapImageData = snapCtx.createImageData(width, height);\n"
  " var snapPixels = snapImageData.data;\n"
  " \n"
  " // Convert RGB565 to RGBA\n"
  " for(var i = 0; i < snapshotData.length; i++) {\n"
  "  var base = i * 4;\n"
  "  snapPixels[base+0] = (snapshotData[i] & 0xf800) >> 8 | (snapshotData[i] & 0xe000) >> 13;  // Red\n"
  "  snapPixels[base+1] = (snapshotData[i] & 0x07e0) >> 3 | (snapshotData[i] & 0x0600) >> 9;   // Green\n"
  "  snapPixels[base+2] = (snapshotData[i] & 0x001f) << 3 | (snapshotData[i] & 0x001c) >> 2;   // Blue\n"
  "  snapPixels[base+3] = 255;  // Alpha\n"
  " }\n"
  " \n"
  " snapCtx.putImageData(snapImageData, 0, 0);\n"
  " \n"
  " // 存储当前快照的canvas引用\n"
  " currentSnapshotCanvas = canvas;\n"
  " \n"
  " // 显示当前快照\n"
  " var imgElement = document.getElementById('currentSnapshot');\n"
  " imgElement.src = canvas.toDataURL();\n"
  " imgElement.style.display = 'block';\n"
  " \n"
  " Msg('拍照完成!点击 \"保存当前照片\" 进行保存');\n"
  "}\n"
  
  "function handleSnapshotData(data) {\n"
  " // Handle text-based snapshot data (if implemented)\n"
  " console.log('Snapshot data: ' + data);\n"
  "}\n"
  "</script>\n"    
  "</div>\n"
  "</body>\n"
  "</html>\n";

// WebSocket protocol constants
#define WS_FIN   0x80
#define OP_TEXT  0x81
#define OP_BIN   0x82
#define OP_CLOSE 0x88
#define OP_PING  0x89
#define OP_PONG  0x8A
#define WS_MASK  0x80

// Global variables for image data transmission
uint8_t *WScamData = nullptr;
uint16_t data_size = 0;
uint16_t line_size = 0;
uint16_t line_h = 0;

// Snapshot variables
bool snapshotRequested = false;
bool snapshotInProgress = false;
uint16_t snapshotBuffer[CAM_WIDTH * CAM_HEIGHT];  // Buffer for snapshot data
uint16_t snapshotIndex = 0;

// WiFi connection function
bool wifi_connect(){
  wifiMulti.addAP("zaixinjian", "2020zaixinjian");  // WiFi credentials
  // Add more APs as needed
  
  Serial.println(F("Connecting Wifi..."));
  if(wifiMulti.run() == WL_CONNECTED) {
    WiFi.config(myIP, myGateway, IPAddress(255,255,255,0));  // Set static IP
      
    Serial.println(F("--- WiFi connected ---"));
    Serial.print(F("SSID: "));
    Serial.println(WiFi.SSID());
    Serial.print(F("IP Address: "));
    Serial.println(WiFi.localIP());
    Serial.print(F("signal strength (RSSI): "));
    Serial.print(WiFi.RSSI());  // Signal strength
    Serial.println(F("dBm"));      
    return true;
  }
  else return false;
}

// Send HTML page to client
void printHTML(WiFiClient &client){
  Serial.println("sendHTML ...");
  client.print(html_head);
  Serial.println("head done");

  client.print(html_body);
  client.print(WiFi.localIP());
  client.println(F("/';"));
  Serial.println("body done");
  client.println(html_script);
  Serial.println("sendHTML Done");    
}

// Generate WebSocket accept key
String Hash_Key(String h_req_key){
  unsigned char hash[20];
  String str = h_req_key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11";  
  esp_sha(SHA1, (unsigned char*)str.c_str(), str.length(), hash);
  str = base64::encode(hash, 20);
  return str;  
}

// WebSocket handshake procedure
void WS_handshake(WiFiClient &client){
  String req;
  String hash_req_key;
  
  Serial.println(F("-----from Browser HTTP WebSocket Request---------"));
  // Read browser request until empty line
  do{
    req = client.readStringUntil('\n');  // Read until newline
    Serial.println(req);
    if(req.indexOf("Sec-WebSocket-Key") >= 0){
      hash_req_key = req.substring(req.indexOf(':')+2, req.indexOf('\r'));
      Serial.println();
      Serial.print(F("hash_req_key ="));
      Serial.println(hash_req_key);
    }        
  }while(req.indexOf("\r") != 0);
  
  req = "";
  delay(10);

  // Send WebSocket handshake response
  Serial.println(F("---send WS HTML..."));
  String str = "HTTP/1.1 101 Switching Protocols\r\n";
  str += "Upgrade: websocket\r\n";
  str += "Connection: Upgrade\r\n";
  str += "Sec-WebSocket-Accept: ";
  str += Hash_Key(hash_req_key);  // Hash -> BASE64 encoded key
  str += "\r\n\r\n";              // Empty line is required
  Serial.println(str);
  client.print(str);              // Send to client
  str = "";
  WSclient = client;
}

// Handle WebSocket messages
void handleWebSocketMessage(uint8_t *data, size_t len) {
  if (len >= 7 && strncmp((char*)data, "SNAPSHOT", 7) == 0) {
    Serial.println("Snapshot requested");
    snapshotRequested = true;
  }
}

// Process WebSocket data
void processWebSocketData(uint8_t *data, size_t len) {
  if (len < 2) return;
  
  uint8_t opcode = data[0] & 0x0F;
  bool isMasked = (data[1] & 0x80) != 0;
  uint64_t payloadLength = data[1] & 0x7F;
  uint8_t maskIndex = 2;
  
  if (payloadLength == 126) {
    if (len < 4) return;
    payloadLength = (data[2] << 8) | data[3];
    maskIndex = 4;
  } else if (payloadLength == 127) {
    if (len < 10) return;
    // For 64-bit length, we don't handle extremely large payloads
    return;
  }
  
  if (isMasked) {
    if (len < maskIndex + 4) return;
    uint8_t maskingKey[4] = {data[maskIndex], data[maskIndex+1], data[maskIndex+2], data[maskIndex+3]};
    maskIndex += 4;
    
    // Unmask the payload
    for (size_t i = 0; i < payloadLength && i + maskIndex < len; i++) {
      data[maskIndex + i] ^= maskingKey[i % 4];
    }
  }
  
  if (opcode == OP_TEXT) {
    handleWebSocketMessage(data + maskIndex, payloadLength);
  }
}

// Check for WebSocket messages
void checkWebSocketMessages() {
  if (!WSclient.available()) return;
  
  static uint8_t wsBuffer[128];
  static size_t wsBufferIndex = 0;
  
  while (WSclient.available()) {
    uint8_t b = WSclient.read();
    wsBuffer[wsBufferIndex++] = b;
    
    if (wsBufferIndex >= sizeof(wsBuffer)) {
      // Buffer full, process it
      processWebSocketData(wsBuffer, wsBufferIndex);
      wsBufferIndex = 0;
    }
  }
  
  // Process any remaining data
  if (wsBufferIndex > 0) {
    processWebSocketData(wsBuffer, wsBufferIndex);
    wsBufferIndex = 0;
  }
}

// Handle HTTP requests and WebSocket initiation
void Ini_HTTP_Response(void){
  String req;
  
  WiFiClient client = server.available();  // Check for client connections
  if(!client) return;                      // Exit if no client
  
  while(client.connected()){               // While client is connected
    if(!client.available()) break;         // Exit if no data available
    Serial.println(F("----Client Receive----"));    
    req = client.readStringUntil('\n');    // Read one line

    if(req.indexOf("GET / HTTP") != -1){   // Browser request detected
      while(req.indexOf("\r") != 0){       // Read until empty line
        req = client.readStringUntil('\n'); 
        Serial.println(req);
        if(req.indexOf("websocket") != -1){
          Serial.println(F("\nPrint WS HandShake---"));          
          WS_handshake(client);            // Complete WebSocket handshake
          WS_on = true;                    // Set WebSocket flag
          return;
        }
      }        
      delay(10);                           // Wait before sending response
      Serial.println(F("\nPrint HTML-----------"));
      printHTML(client);                   // Send HTML response
      Serial.println(F("\nPrint HTML end-------"));
    }
    else{                                  // Handle other requests (favicon, etc.)
      Serial.println(F("*** Another Request ***"));
      Serial.print(req);
      while(client.available()){
        Serial.write(client.read());       // Read all incoming data
      }
    }
    if(!WS_on){
      delay(1);                            // Important for proper disconnection
      client.stop();                       // Disconnect from browser
      delay(1);
      Serial.println(F("===== Client stop ====="));
      req = "";
    }
  }
}

// Initialize image transmission header
bool setImgHeader(uint16_t w, uint16_t h){
  line_h = h;
  line_size = w * 2;
  data_size = 2 + line_size * h;              // (LineNo + img) byte count
  
  // Allocate memory only if not already allocated
  if(WScamData == nullptr){
    WScamData = (uint8_t*)malloc(data_size + 4);  // + head size
    if(WScamData == nullptr){
      Serial.println(F("******** Memory allocate Error! ***********"));
      return false;
    }
    Serial.println("WS Buffer Keep OK");
  }
  
  // Set WebSocket frame header
  WScamData[0] = OP_BIN;                      // Binary data transmission header
  WScamData[1] = 126;                         // 126: next 2 bytes indicate data length
  WScamData[2] = (uint8_t)(data_size / 256);  // Data length (High byte)
  WScamData[3] = (uint8_t)(data_size % 256);  // Data length (Low byte)
  return true;  
}

#define UNIT_SIZE 2048  // Chunk size for data transmission

// Send image data via WebSocket
void WS_sendImg(uint16_t lineNo){
  uint16_t len, send_size;
  uint8_t *pData;

  // Set line number in data buffer
  WScamData[4] = (uint8_t)(lineNo % 256);
  WScamData[5] = (uint8_t)(lineNo / 256);

  len = data_size + 4;
  pData = WScamData;
  while(len){
    send_size = (len > UNIT_SIZE) ? UNIT_SIZE : len;    
    WSclient.write(pData, send_size);  // Send WebSocket data (chunked)
    len -= send_size;
    pData += send_size;
  }
}

// Send snapshot data via WebSocket
void WS_sendSnapshot(uint16_t lineNo, uint16_t* data){
  uint16_t len, send_size;
  uint8_t header[6];
  
  // Create WebSocket header
  header[0] = OP_BIN;
  header[1] = 126;
  uint16_t payloadLength = 2 + line_size;  // Line number + line data
  header[2] = (uint8_t)(payloadLength / 256);
  header[3] = (uint8_t)(payloadLength % 256);
  header[4] = (uint8_t)(lineNo % 256);
  header[5] = (uint8_t)(lineNo / 256);
  
  // Send header
  WSclient.write(header, 6);
  
  // Send image data
  uint8_t *imgData = (uint8_t*)data;
  len = line_size;
  while(len){
    send_size = (len > UNIT_SIZE) ? UNIT_SIZE : len;
    WSclient.write(imgData, send_size);
    len -= send_size;
    imgData += send_size;
  }
}

// Setup function
void setup() {
  Serial.begin(115200);
  Serial.println(F("OV7670 Web with Snapshot")); 
  Wire.begin();
  Wire.setClock(400000);  // I2C clock speed

  WS_on = false;
  if(wifi_connect()){ 
    server.begin();        // Start listening for clients
  }
  
  Serial.println(F("---- cam init ----"));   
  esp_err_t err = cam.init(&cam_conf, CAM_RES, RGB565);  // Initialize camera
  if(err != ESP_OK){
    Serial.println(F("cam.init ERROR"));
    while(1);  // Halt on error
  }
  
  cam.vflip(false);  // Flip image vertically
   
  Serial.printf("cam MID = %X\n\r", cam.getMID());
  Serial.printf("cam PID = %X\n\r", cam.getPID());
  
  Serial.println(F("---- cam init done ----"));
  
  // Pre-allocate memory for image transmission
  if(!setImgHeader(CAM_WIDTH, CAM_HEIGHT / CAM_DIV)){
    Serial.println(F("Memory allocation failed!"));
    while(1);  // Halt on error
  }
}

// Main loop
void loop(void){
  uint16_t y, dy;
  
  dy = CAM_HEIGHT / CAM_DIV;  // Number of lines to send at once

  while(1){
    // Check for WebSocket messages
    if(WS_on && WSclient){
      checkWebSocketMessages();
    }
    
    // Handle snapshot request
    if(snapshotRequested){
      snapshotRequested = false;
      snapshotInProgress = true;
      snapshotIndex = 0;
      Serial.println("Starting snapshot capture");
      
      // Capture entire frame
      for(y = 0; y < CAM_HEIGHT; y += dy){
        cam.getLines(y+1, (uint8_t*)&snapshotBuffer[snapshotIndex], dy);
        snapshotIndex += CAM_WIDTH * dy;
        
        // Send progress update
        if(WS_on && WSclient){
          String progress = "SNAPSHOT:" + String(y * 100 / CAM_HEIGHT) + "%";
          WSclient.print(progress);
        }
      }
      
      // Send complete snapshot
      if(WS_on && WSclient){
        for(y = 0; y < CAM_HEIGHT; y += dy){
          WS_sendSnapshot(y, &snapshotBuffer[y * CAM_WIDTH]);
        }
        WSclient.print("SNAPSHOT:COMPLETE");
      }
      
      snapshotInProgress = false;
      Serial.println("Snapshot complete");
    }
    
    // Normal streaming
    for(y = 0; y < CAM_HEIGHT; y += dy){      
      cam.getLines(y+1, &WScamData[6], dy);  // Get dy lines from camera (LineNo starts at 1)
      if(WS_on && !snapshotInProgress){
        if(WSclient){
          WS_sendImg(y);                     // Send image via WebSocket
        }
        else{
          WSclient.stop();                   // Disconnect if connection lost
          WS_on = false;
          Serial.println(F("====< Client Stop >===="));
        }
      }
    }
    if(!WS_on){
      Ini_HTTP_Response();                   // Handle new HTTP requests
    }
  }
}

程序流程图

三、OV7670摄像头模块工作原理

        OV7670摄像头需要通过SCCB(Serial Camera Control Bus)接口配置内部寄存器才能正常工作。

3.1 寄存器配置

(1) 时钟配置寄存器

// 时钟控制寄存器配置示例
{0x11, 0x80},  // CLKRC:内部时钟控制,使用外部时钟源
{0x6b, 0x40},  // PLL控制寄存器,设置PLL倍频

  

寄存器中的【5:0】控制我们输入时钟分频,通过 “寄存器特定位写值→按‘值 + 1’算分频系数→输入时钟除以系数” 的逻辑,实现对设备(OV7670)工作时钟的精准控制。

        →时钟频率越高,芯片内部处理像素数据的速度越快,单位时间内输出的完整图像(帧)就越多,帧率自然越高。

        →当 bit6 设为 1 时,OV7670 会跳过分频步骤,直接使用外部输入的原始时钟

(2) 图像格式和分辨率配置

// 图像格式配置
{0x12, 0x14},  // COM7:选择QVGA分辨率和RGB输出
{0x40, 0x10},  // COM15:RGB565格式,全范围输出
{0x0C, 0x04},  // COM3:启用缩放功能
{0x3E, 0x19},  // COM14:缩放参数

本项目使用QQVGA分辨率,通过 bit4 选择 QVGA(320,240)作为基础分辨率,再结合 0x32、0x17~0x1A 等寄存器进一步裁剪画面尺寸至 160x120。 

        ① 分辨率(QQVGA):bit4=1 → 对应二进制00010000;

        ② 图像输出格式(RGB):bit2=1、bit0=0 → 对应二进制00000100

        ③ 合并后二进制:00010100 → 十六进制0x14,即最终写入 0x12 寄存器的值

(3)图像效果调整

// 图像效果调整
{0x55, 0x00},  // 亮度控制
{0x56, 0x60},  // 对比度控制
{0x57, 0x80},  // 对比度中心
{0x13, 0xE7},  // COM8:启用AGC、AEC和白平衡
{0x6F, 0x40},  // AWB蓝色增益
{0x70, 0x40},  // AWB红色增益

  

调整图像的亮度、对比度、白平衡等参数,优化图像质量

3.2 输出图像数据时序

(1)数据采集时序

一个 VS 周期(一帧)内,HS 的一个完整周期对应一行图像数据

  

配置的图像分辨率是 160×120(宽 160像素、高 120行):

        一个 VS 周期内就会有 120个 HS 周期;结合 15 帧 / 秒的帧率,1 秒内 HS 的总周期数就是 “15 帧 ×120行 / 帧 = 1800 个”,对应 1 秒传输 1800 行像素数据。

(2) RGB 565 输出时序

PCLK像素时钟控制 “单个字节数据” 的读取,始终规律跳变,但仅在 HS 高电平(行传输期间)的信号有效

  

PCLK 下降沿:OV7670 更新 D0~D7 引脚的字节数据; PCLK 上升沿:单片机读取 D0~D7 的字节数据

static const struct regval_list qqvga_OV7670[] PROGMEM = {	// 160 x 120
	{REG_COM3,  COM3_DCWEN},				// Enable format scaling
	{REG_COM14, COM14_DCWEN | COM14_MANUAL | COM14_PCLKDIV_4},	// divide by 4
	{REG_SCALING_XSC,	0x3a},		// Horizontal scale factor
	{REG_SCALING_YSC,	0x35},		// Vertical scale factor
	{REG_SCALING_DCWCTR, SCALING_DCWCTR_VDS_by_4 | SCALING_DCWCTR_HDS_by_4},	// down sampling by 4 
	{REG_SCALING_PCLK_DIV, SCALING_PCLK_DIV_RSVD | SCALING_PCLK_DIV_4},	// DSP scale control Clock divide by 4
	{REG_SCALING_PCLK_DELAY,0x02},
	{0xff, 0xff}				// END MARKER
};

在本项目中,获取QQVGA分辨率(160x120)是通过硬件配置实现的,直接配置OV7670摄像头的内部寄存器,使其直接输出目标分辨率:

        REG_COM3: 启用格式缩放(COM3_DCWEN)。
        REG_COM14: 启用下降采样、手动控制,并设置PCLK分频为4(COM14_DCWEN | COM14_MANUAL | COM14_PCLKDIV_4)。

四、项目结果演示

4.1 零知IDE操作

①按照接线图正确连接ESP32和OV7670摄像头

②连接USB线并将代码烧录到ESP32

串口打印输出摄像头设备号,

cam MID = 7FA2、cam PID = 7673,说明初始化成功

③使用手机或电脑连接ESP32创建的WiFi网络

④浏览器打开ESP32的IP地址(默认为192.168.3.78)

  

⑤网页中将显示实时视频流

⑥点击"拍照"按钮拍摄照片

4.2 效果展示

 拍照效果示例:点击拍摄并保存可以通过下方的下拉栏选中查看并删除照片

 

4.3 演示视频

OV7670摄像头实现简易照相机系统

展示实时视频流和拍照功能

五、常见问题解答

Q1:视频流卡顿严重怎么办?

A:可能的原因是网络带宽不足或ESP32处理能力达到极限:

        尝试降低分辨率或帧率,减少数据传输量。确保WiFi信号强度良好。

Q2:拍摄的照片色彩失真怎么办?

A:这是白平衡或色彩矩阵配置不当导致的:

        调整寄存器0x13、0x6f等白平衡相关参数,参考中的优化配置。

Q3:如何提高图像质量?

A:可以尝试以下方法:

        优化光线条件,避免过暗或过亮环境、调整寄存器0x55、0x56、0x57(亮度、对比度)、修改寄存器0x7a-0x81(伽马曲线)、参考中的寄存器配置进行优化

项目资源整合

        WebSocket协议说明:WebSocket API

        OV7670数据手册:OV7670 (REV G.)   

        OV7670库文件:  OV7670-ESP32-master

🔍 本项目特别适合对嵌入式系统、图像处理和物联网技术感兴趣的开发者学习参考。如有任何问题或建议,欢迎在评论区留言交流!

本文章已经生成可运行项目
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值