✔零知IDE 是一个真正属于国人自己的开源软件平台,在开发效率上超越了Arduino平台并且更加容易上手,大大降低了开发难度。零知开源在软件方面提供了完整的学习教程和丰富示例代码,让不懂程序的工程师也能非常轻而易举的搭建电路来创作产品,测试产品。快来动手试试吧!
✔访问零知实验室,获取更多实战项目和教程资源吧!
目录
(1)项目概述
本项目基于零知ESP32开发板和OV7670摄像头模块,实现了一个功能完整的简易照相机系统。系统采用QQVGA(160×120)分辨率,RGB565色彩格式,在保证图像质量的同时控制数据传输量,确保ESP32能够稳定处理。通过优化的WebSocket传输协议,实现了在网页端实时显示摄像头画面和拍照功能。
(2)项目难点及解决方案
问题描述1:ESP32 WROOM内存有限
解决方案:采用预分配内存策略,分配好图像传输缓冲区,避免运行时动态内存分配
问题描述2:WebSocket实时传输视频流需要高效的数据压缩
解决方案:将每帧图像分成多行传输,减少单次数据传输量
一、硬件接线部分
1.1 硬件清单
| 组件名称 | 规格型号 | 数量 | 备注 |
|---|---|---|---|
| 主控板 | 零知ESP32 WROOM | 1 | 核心处理单元 |
| 摄像头模块 | OV7670 | 1 | 30万像素,支持RGB565输出 |
| 连接线 | 杜邦线 | 若干 | 用于各模块间连接 |
| 电源 | USB数据线 | 1 | 5V供电 |
| 电阻 | 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
🔍 本项目特别适合对嵌入式系统、图像处理和物联网技术感兴趣的开发者学习参考。如有任何问题或建议,欢迎在评论区留言交流!
2513

被折叠的 条评论
为什么被折叠?



