跳到主要内容ESP32 驱动 OV7670 摄像头实现简易照相机系统 | 极客日志C++AI大前端算法
ESP32 驱动 OV7670 摄像头实现简易照相机系统
综述由AI生成基于 ESP32 微控制器和 OV7670 摄像头模块构建简易照相机系统的完整方案。系统采用 QQVGA 分辨率,利用 WebSocket 协议实现网页端实时视频流传输与拍照功能。内容涵盖硬件接线、ESP32 固件代码解析、摄像头寄存器配置原理、图像数据时序处理及常见问题排查。重点解决了内存分配优化和实时视频流压缩传输问题,适合嵌入式开发与物联网应用学习。
女王32 浏览 ESP32 驱动 OV7670 摄像头实现简易照相机系统
项目概述
本项目基于 ESP32 开发板和 OV7670 摄像头模块,实现了一个功能完整的简易照相机系统。系统采用 QQVGA(160×120) 分辨率,RGB565 色彩格式,在保证图像质量的同时控制数据传输量,确保 ESP32 能够稳定处理。通过优化的 WebSocket 传输协议,实现了在网页端实时显示摄像头画面和拍照功能。
项目难点及解决方案
问题描述 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,
.D1 = 39,
.D2 = 34,
.D3 = 35,
.D4 = 32,
.D5 = 33,
.D6 = 25,
.D7 = 26,
.XCLK = 15,
.PCLK = 14,
.VSYNC = ,
.xclk_freq_hz = ,
.ledc_timer = LEDC_TIMER_0,
.ledc_channel = LEDC_CHANNEL_0
};
13
10000000
注意: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, .D1 = 39,
.xclk_freq_hz = 10000000,
.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;
data_size = 2 + line_size * h;
WScamData = (uint8_t*)malloc(data_size + 4);
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){
cam.getLines(y+1, &WScamData[6], dy);
if(WS_on && !snapshotInProgress){
if(WSclient){
WS_sendImg(y);
}
}
}
if(!WS_on){
Ini_HTTP_Response();
}
}
}
2.5 完整代码
#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>
IPAddress myIP = IPAddress(192, 168, 3, 78);
IPAddress myGateway = IPAddress(192, 168, 3, 1);
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,
.ledc_timer = LEDC_TIMER_0,
.ledc_channel = LEDC_CHANNEL_0
};
#define CAM_RES QQVGA
#define CAM_WIDTH 160
#define CAM_HEIGHT 120
#define CAM_DIV 1
OV7670 cam;
WiFiServer server(80);
WiFiClient WSclient;
boolean WS_on = false;
WiFiMulti wifiMulti;
const char *html_head = "HTTP/1.1 200 OK\r\n"
"Content-type:text/html\r\n"
"Connection:close\r\n"
"\r\n"
"<!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>\n"
"<h1>ESP32 OV7670 照相机</h1>\n";
const char *html_body = "<div>WebSocket 正在连接...</div>\n"
"<div>0 fps</div>\n"
"<div>\n"
"<button onclick='takeSnapshot()'>点击拍照</button>\n"
"<button onclick='saveSnapshot()'>保存当前照片</button>\n"
"</div>\n"
"<div>\n"
"<canvas></canvas>\n"
"</div>\n"
"<div>\n"
"<select onchange='showSelectedSnapshot()'>\n"
"<option>-- 选择照片 --</option>\n"
"</select>\n"
"<button onclick='deleteSelectedSnapshot()'>删除所选照片</button>\n"
"</div>\n"
"<div>\n"
"<img alt='选中的照片将显示在这里'>\n"
"</div>\n";
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"
"var currentSnapshotCanvas = null;\n"
"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"
"}\n"
"function fpsShow(){\n"
" 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"
" pixels[base+1] = (buf[c] & 0x07e0) >> 3 | (buf[c] & 0x0600) >> 9;\n"
" pixels[base+2] = (buf[c] & 0x001f) << 3 | (buf[c] & 0x001c) >> 2;\n"
" pixels[base+3] = 255;\n"
" 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"
" 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"
" var selector = document.getElementById('snapshotSelector');\n"
" var option = document.createElement('option');\n"
" option.value = timestamp;\n"
" option.textContent = timestamp;\n"
" selector.appendChild(option);\n"
" selector.value = timestamp;\n"
" showSelectedSnapshot();\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"
" 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"
" if(selectedValue && snapshots[selectedValue]) {\n"
" delete snapshots[selectedValue];\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"
" var imgElement = document.getElementById('currentSnapshot');\n"
" imgElement.src = '';\n"
" imgElement.style.display = 'none';\n"
" selector.value = '';\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"
" for(var i = 1; i < buf.length; i++) {\n"
" if(snapshotIndex < snapshotData.length) {\n"
" snapshotData[snapshotIndex++] = buf[i];\n"
" }\n"
" }\n"
" if(snapshotIndex >= snapshotData.length) {\n"
" completeSnapshot();\n"
" }\n"
"}\n"
"function completeSnapshot() {\n"
" snapshotMode = false;\n"
" var canvas = document.createElement('canvas');\n"
" canvas.width = width;\n"
" canvas.height = height;\n"
" var snapCtx = canvas.getContext('2d');\n"
" var snapImageData = snapCtx.createImageData(width, height);\n"
" var snapPixels = snapImageData.data;\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;\n"
" snapPixels[base+1] = (snapshotData[i] & 0x07e0) >> 3 | (snapshotData[i] & 0x0600) >> 9;\n"
" snapPixels[base+2] = (snapshotData[i] & 0x001f) << 3 | (snapshotData[i] & 0x001c) >> 2;\n"
" snapPixels[base+3] = 255;\n"
" }\n"
" snapCtx.putImageData(snapImageData, 0, 0);\n"
" currentSnapshotCanvas = canvas;\n"
" var imgElement = document.getElementById('currentSnapshot');\n"
" imgElement.src = canvas.toDataURL();\n"
" imgElement.style.display = 'block';\n"
" Msg('拍照完成!点击 \"保存当前照片\" 进行保存');\n"
"}\n"
"function handleSnapshotData(data) {\n"
" console.log('Snapshot data: ' + data);\n"
"}\n";
#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
uint8_t *WScamData = nullptr;
uint16_t data_size = 0;
uint16_t line_size = 0;
uint16_t line_h = 0;
bool snapshotRequested = false;
bool snapshotInProgress = false;
uint16_t snapshotBuffer[CAM_WIDTH * CAM_HEIGHT];
uint16_t snapshotIndex = 0;
bool wifi_connect(){
wifiMulti.addAP("YOUR_SSID", "YOUR_PASSWORD");
Serial.println(F("Connecting Wifi..."));
if(wifiMulti.run() == WL_CONNECTED) {
WiFi.config(myIP, myGateway, IPAddress(255,255,255,0));
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());
Serial.println(F("dBm"));
return true;
} else return false;
}
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");
}
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;
}
void WS_handshake(WiFiClient &client){
String req;
String hash_req_key;
Serial.println(F("-----from Browser HTTP WebSocket Request---------"));
do{
req = client.readStringUntil('\n');
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);
delay(10);
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);
str += "\r\n\r\n";
Serial.println(str);
client.print(str);
WSclient = client;
}
void handleWebSocketMessage(uint8_t *data, size_t len) {
if (len >= 7 && strncmp((char*)data, "SNAPSHOT", 7) == 0) {
Serial.println("Snapshot requested");
snapshotRequested = true;
}
}
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;
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;
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);
}
}
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)) {
processWebSocketData(wsBuffer, wsBufferIndex);
wsBufferIndex = 0;
}
}
if (wsBufferIndex > 0) {
processWebSocketData(wsBuffer, wsBufferIndex);
wsBufferIndex = 0;
}
}
void Ini_HTTP_Response(void){
String req;
WiFiClient client = server.available();
if(!client) return;
while(client.connected()){
if(!client.available()) break;
Serial.println(F("----Client Receive----"));
req = client.readStringUntil('\n');
if(req.indexOf("GET / HTTP") != -1){
while(req.indexOf("\r") != 0){
req = client.readStringUntil('\n');
Serial.println(req);
if(req.indexOf("websocket") != -1){
Serial.println(F("\nPrint WS HandShake---"));
WS_handshake(client);
WS_on = true;
return;
}
}
delay(10);
Serial.println(F("\nPrint HTML-----------"));
printHTML(client);
Serial.println(F("\nPrint HTML end-------"));
} else{
Serial.println(F("*** Another Request ***"));
Serial.print(req);
while(client.available()){ Serial.write(client.read()); }
}
if(!WS_on){
delay(1);
client.stop();
delay(1);
Serial.println(F("===== Client stop ====="));
}
}
}
void setup() {
Serial.begin(115200);
Serial.println(F("OV7670 Web with Snapshot"));
Wire.begin();
Wire.setClock(400000);
WS_on = false;
if(wifi_connect()){
server.begin();
}
Serial.println(F("---- cam init ----"));
esp_err_t err = cam.init(&cam_conf, CAM_RES, RGB565);
if(err != ESP_OK){
Serial.println(F("cam.init ERROR"));
while(1);
}
cam.vflip(false);
Serial.printf("cam MID = %X\n\r", cam.getMID());
Serial.printf("cam PID = %X\n\r", cam.getPID());
Serial.println(F("---- cam init done ----"));
if(!setImgHeader(CAM_WIDTH, CAM_HEIGHT / CAM_DIV)){
Serial.println(F("Memory allocation failed!"));
while(1);
}
}
void loop(void){
uint16_t y, dy;
dy = CAM_HEIGHT / CAM_DIV;
while(1){
if(WS_on && WSclient){
checkWebSocketMessages();
}
if(snapshotRequested){
snapshotRequested = false;
snapshotInProgress = true;
snapshotIndex = 0;
Serial.println("Starting snapshot capture");
for(y = 0; y < CAM_HEIGHT; y += dy){
cam.getLines(y+1, (uint8_t*)&snapshotBuffer[snapshotIndex], dy);
snapshotIndex += CAM_WIDTH * dy;
if(WS_on && WSclient){
String progress = "SNAPSHOT:" + String(y * 100 / CAM_HEIGHT) + "%";
WSclient.print(progress);
}
}
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");
}
for(y = 0; y < CAM_HEIGHT; y += dy){
cam.getLines(y+1, &WScamData[6], dy);
if(WS_on && !snapshotInProgress){
if(WSclient){
WS_sendImg(y);
} else{
WSclient.stop();
WS_on = false;
Serial.println(F("====< Client Stop >===="));
}
}
}
if(!WS_on){
Ini_HTTP_Response();
}
}
}
三、OV7670 摄像头模块工作原理
OV7670 摄像头需要通过 SCCB(Serial Camera Control Bus)接口配置内部寄存器才能正常工作。
3.1 寄存器配置
(1)时钟配置寄存器
{0x11, 0x80},
{0x6b, 0x40},
寄存器中的【5:0】控制我们输入时钟分频,通过'寄存器特定位写值→按'值 + 1'算分频系数→输入时钟除以系数'的逻辑,实现对设备工作时钟的精准控制。
- 时钟频率越高,芯片内部处理像素数据的速度越快,单位时间内输出的完整图像(帧)就越多,帧率自然越高。
- 当 bit6 设为 1 时,OV7670 会跳过分频步骤,直接使用外部输入的原始时钟
(2)图像格式和分辨率配置
{0x12, 0x14},
{0x40, 0x10},
{0x0C, 0x04},
{0x3E, 0x19},
本项目使用 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},
{0x6F, 0x40},
{0x70, 0x40},
调整图像的亮度、对比度、白平衡等参数,优化图像质量。
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 更新 D0D7 引脚的字节数据;PCLK 上升沿:单片机读取 D0D7 的字节数据。
static const struct regval_list qqvga_OV7670[] PROGMEM = {
{REG_COM3, COM3_DCWEN},
{REG_COM14, COM14_DCWEN | COM14_MANUAL | COM14_PCLKDIV_4},
{REG_SCALING_XSC, 0x3a},
{REG_SCALING_YSC, 0x35},
{REG_SCALING_DCWCTR, SCALING_DCWCTR_VDS_by_4 | SCALING_DCWCTR_HDS_by_4},
{REG_SCALING_PCLK_DIV, SCALING_PCLK_DIV_RSVD | SCALING_PCLK_DIV_4},
{REG_SCALING_PCLK_DELAY,0x02},
{0xff, 0xff}
};
在本项目中,获取 QQVGA 分辨率(160x120)是通过硬件配置实现的,直接配置 OV7670 摄像头的内部寄存器,使其直接输出目标分辨率:
- REG_COM3: 启用格式缩放(COM3_DCWEN)。
- REG_COM14: 启用下降采样、手动控制,并设置 PCLK 分频为 4(COM14_DCWEN | COM14_MANUAL | COM14_PCLKDIV_4)。
四、项目结果演示
4.1 烧录与调试
- 按照接线图正确连接 ESP32 和 OV7670 摄像头
- 连接 USB 线并将代码烧录到 ESP32
串口打印输出摄像头设备号,cam MID = 7FA2、cam PID = 7673,说明初始化成功
- 使用手机或电脑连接 ESP32 创建的 WiFi 网络
- 浏览器打开 ESP32 的 IP 地址(默认为 192.168.3.78)

- 网页中将显示实时视频流
- 点击'拍照'按钮拍摄照片
4.2 效果展示
拍照效果示例:点击拍摄并保存可以通过下方的下拉栏选中查看并删除照片
4.3 演示视频
五、常见问题解答
Q1:视频流卡顿严重怎么办?
A: 可能的原因是网络带宽不足或 ESP32 处理能力达到极限:
- 尝试降低分辨率或帧率,减少数据传输量。确保 WiFi 信号强度良好。
Q2:拍摄的照片色彩失真怎么办?
- 调整寄存器 0x13、0x6f 等白平衡相关参数,参考文中的优化配置。
Q3:如何提高图像质量?
- 优化光线条件,避免过暗或过亮环境、调整寄存器 0x55、0x56、0x57(亮度、对比度)、修改寄存器 0x7a-0x81(伽马曲线)、参考文中的寄存器配置进行优化
参考资料
相关免费在线工具
- 加密/解密文本
使用加密算法(如AES、TripleDES、Rabbit或RC4)加密和解密文本明文。 在线工具,加密/解密文本在线工具,online
- RSA密钥对生成器
生成新的随机RSA私钥和公钥pem证书。 在线工具,RSA密钥对生成器在线工具,online
- Mermaid 预览与可视化编辑
基于 Mermaid.js 实时预览流程图、时序图等图表,支持源码编辑与即时渲染。 在线工具,Mermaid 预览与可视化编辑在线工具,online
- 随机西班牙地址生成器
随机生成西班牙地址(支持马德里、加泰罗尼亚、安达卢西亚、瓦伦西亚筛选),支持数量快捷选择、显示全部与下载。 在线工具,随机西班牙地址生成器在线工具,online
- Gemini 图片去水印
基于开源反向 Alpha 混合算法去除 Gemini/Nano Banana 图片水印,支持批量处理与下载。 在线工具,Gemini 图片去水印在线工具,online
- Base64 字符串编码/解码
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online