✔零知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