当前位置: 首页 > news >正文

零知开源——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 frameOV7670 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 neededSerial.println(F("Connecting Wifi..."));if(wifiMulti.run() == WL_CONNECTED) {WiFi.config(myIP, myGateway, IPAddress(255,255,255,0));  // Set static IPSerial.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 strengthSerial.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 linedo{req = client.readStringUntil('\n');  // Read until newlineSerial.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 responseSerial.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 keystr += "\r\n\r\n";              // Empty line is requiredSerial.println(str);client.print(str);              // Send to clientstr = "";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 payloadsreturn;}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 payloadfor (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 itprocessWebSocketData(wsBuffer, wsBufferIndex);wsBufferIndex = 0;}}// Process any remaining dataif (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 connectionsif(!client) return;                      // Exit if no clientwhile(client.connected()){               // While client is connectedif(!client.available()) break;         // Exit if no data availableSerial.println(F("----Client Receive----"));    req = client.readStringUntil('\n');    // Read one lineif(req.indexOf("GET / HTTP") != -1){   // Browser request detectedwhile(req.indexOf("\r") != 0){       // Read until empty linereq = client.readStringUntil('\n'); Serial.println(req);if(req.indexOf("websocket") != -1){Serial.println(F("\nPrint WS HandShake---"));          WS_handshake(client);            // Complete WebSocket handshakeWS_on = true;                    // Set WebSocket flagreturn;}}        delay(10);                           // Wait before sending responseSerial.println(F("\nPrint HTML-----------"));printHTML(client);                   // Send HTML responseSerial.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 disconnectionclient.stop();                       // Disconnect from browserdelay(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 allocatedif(WScamData == nullptr){WScamData = (uint8_t*)malloc(data_size + 4);  // + head sizeif(WScamData == nullptr){Serial.println(F("******** Memory allocate Error! ***********"));return false;}Serial.println("WS Buffer Keep OK");}// Set WebSocket frame headerWScamData[0] = OP_BIN;                      // Binary data transmission headerWScamData[1] = 126;                         // 126: next 2 bytes indicate data lengthWScamData[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 bufferWScamData[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 headerheader[0] = OP_BIN;header[1] = 126;uint16_t payloadLength = 2 + line_size;  // Line number + line dataheader[2] = (uint8_t)(payloadLength / 256);header[3] = (uint8_t)(payloadLength % 256);header[4] = (uint8_t)(lineNo % 256);header[5] = (uint8_t)(lineNo / 256);// Send headerWSclient.write(header, 6);// Send image datauint8_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 speedWS_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 cameraif(err != ESP_OK){Serial.println(F("cam.init ERROR"));while(1);  // Halt on error}cam.vflip(false);  // Flip image verticallySerial.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 transmissionif(!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 oncewhile(1){// Check for WebSocket messagesif(WS_on && WSclient){checkWebSocketMessages();}// Handle snapshot requestif(snapshotRequested){snapshotRequested = false;snapshotInProgress = true;snapshotIndex = 0;Serial.println("Starting snapshot capture");// Capture entire framefor(y = 0; y < CAM_HEIGHT; y += dy){cam.getLines(y+1, (uint8_t*)&snapshotBuffer[snapshotIndex], dy);snapshotIndex += CAM_WIDTH * dy;// Send progress updateif(WS_on && WSclient){String progress = "SNAPSHOT:" + String(y * 100 / CAM_HEIGHT) + "%";WSclient.print(progress);}}// Send complete snapshotif(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 streamingfor(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 lostWS_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

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


文章转载自:

http://g3d2Cn91.tkchm.cn
http://7quR7YBZ.tkchm.cn
http://K8nucXgb.tkchm.cn
http://zKlaIuNI.tkchm.cn
http://ZTUHrL8j.tkchm.cn
http://eI6u2xZZ.tkchm.cn
http://B2LJwbdn.tkchm.cn
http://HbeLQLpm.tkchm.cn
http://LG7FOx1C.tkchm.cn
http://VaRzD6r0.tkchm.cn
http://soniPENT.tkchm.cn
http://L002SchP.tkchm.cn
http://77I16LQQ.tkchm.cn
http://e5qNbbTP.tkchm.cn
http://eJAopNX9.tkchm.cn
http://2cKnTu5g.tkchm.cn
http://MpGPsjtQ.tkchm.cn
http://iePvYIQu.tkchm.cn
http://gBHizj6T.tkchm.cn
http://0k9h2SPE.tkchm.cn
http://tLVu1eU8.tkchm.cn
http://uW710ZjN.tkchm.cn
http://EX35r9xV.tkchm.cn
http://s1eM871T.tkchm.cn
http://MPapuscu.tkchm.cn
http://BYvrOiyH.tkchm.cn
http://kLtATWA9.tkchm.cn
http://J5w6Ftko.tkchm.cn
http://kLQN47sp.tkchm.cn
http://pVaFMEU0.tkchm.cn
http://www.dtcms.com/a/375901.html

相关文章:

  • 前端开发工具trae的使用
  • Coze源码分析-资源库-创建插件-前端源码-核心组件
  • 数据集成平台怎么选?从ETL到CDC再到iPaaS的全景对比
  • 【Linux基础】Linux系统配置IP详解:从入门到精通
  • 2025版基于springboot的企业考勤管理系统
  • 【计算机毕业设计选题】2025-2026年计算机毕业设计选题经验与项目推荐
  • Python数据处理管道完全指南:从基础到高并发系统实战
  • VMware安装CentOS 7教程
  • SpringBoot + MinIO/S3 文件服务实现:FileService 接口与 FileServiceImpl 详解
  • 如何确定丝杆升降机的额定负载和峰值负载?
  • AI 与 Web3 技术写作大赛,瓜分 2000RMB
  • git 合并多条commit
  • 联邦学习指导、代码、实验、创新点
  • 开源 C++ QT Widget 开发(十五)多媒体--音频播放
  • 绿算技术闪耀智博会 赋能乡村振兴与产业升级
  • 差分数组(Difference Array)
  • 【硬核测评】格行ASR芯片+智能切网算法源码级解析(附高铁场景切换成功率99%方案)
  • 【git】首次clone的使用采用-b指定了分支,还使用了--depth=1 后续在这个基础上拉取所有的分支代码方法
  • AI时尚革命:Google Nano Banana如何颠覆传统穿搭创作
  • OpenCV 高阶 图像金字塔 用法解析及案例实现
  • 【系统分析师】第19章-关键技术:大数据处理系统分析与设计(核心总结)
  • Gears实测室:第一期·音游跨设备性能表现与工具价值实践
  • Next.js中服务器端渲染 (SSR) 详解:动态内容与 SEO 的完美结合
  • C++学习记录(7)vector
  • 【代码随想录算法训练营——Day7】哈希表——454.四数相加II、383.赎金信、15.三数之和、18.四数之和
  • IT 资产管理系统与 IT 服务管理:构建企业数字化的双引擎
  • 手搓Spring
  • LeetCode热题100--230. 二叉搜索树中第 K 小的元素--中等
  • element-plus表格默认展开有子的数据
  • 高带宽的L2 Cache的诀窍