ESP32-S3+OV2640简单推流到GO服务
先做个简单的例子
1.使用ESP32-S3+OV2640连接WIFI连接到局域网
2.把ESP32-S3从OV2640获取到的帧使用http协议推送到Go服务
3.在Go服务中接收视频流保存成AVI格式
4.最后直接用视频播放器播放保存的AVI格式视频即可
一、ESP32-S3代码
使用Arduino开发
c
#include <WiFi.h>
#include <HTTPClient.h>
#include "esp_camera.h"
// WiFi配置
const char* ssid = "wifi";
const char* password = "12345678";
// 服务器配置
const char* serverUrl = "http://192.168.10.176:8007/api/v1/stream";
// OV2640摄像头引脚配置(ESP32-S3)根据你们自己的去修改
#define PWDN_GPIO_NUM -1
#define RESET_GPIO_NUM -1
#define XCLK_GPIO_NUM 10
#define SIOD_GPIO_NUM 40
#define SIOC_GPIO_NUM 39
#define Y9_GPIO_NUM 48
#define Y8_GPIO_NUM 11
#define Y7_GPIO_NUM 12
#define Y6_GPIO_NUM 14
#define Y5_GPIO_NUM 16
#define Y4_GPIO_NUM 18
#define Y3_GPIO_NUM 17
#define Y2_GPIO_NUM 15
#define VSYNC_GPIO_NUM 38
#define HREF_GPIO_NUM 47
#define PCLK_GPIO_NUM 13
// 全局变量
bool cameraInitialized = false;
unsigned long lastFrameTime = 0;
const int frameInterval = 100; // 帧间隔(ms),约10FPS
int frameCount = 0;
int failedCount = 0;
void setup() {
Serial.begin(115200);
delay(1000);
Serial.println("\nESP32-S3 视频录制客户端");
// 初始化摄像头
if (initCamera()) {
Serial.println("摄像头初始化成功");
cameraInitialized = true;
} else {
Serial.println("摄像头初始化失败");
}
// 连接WiFi
connectToWiFi();
Serial.println("初始化完成,开始录制...");
}
bool initCamera() {
camera_config_t config;
config.ledc_channel = LEDC_CHANNEL_0;
config.ledc_timer = LEDC_TIMER_0;
config.pin_d0 = Y2_GPIO_NUM;
config.pin_d1 = Y3_GPIO_NUM;
config.pin_d2 = Y4_GPIO_NUM;
config.pin_d3 = Y5_GPIO_NUM;
config.pin_d4 = Y6_GPIO_NUM;
config.pin_d5 = Y7_GPIO_NUM;
config.pin_d6 = Y8_GPIO_NUM;
config.pin_d7 = Y9_GPIO_NUM;
config.pin_xclk = XCLK_GPIO_NUM;
config.pin_pclk = PCLK_GPIO_NUM;
config.pin_vsync = VSYNC_GPIO_NUM;
config.pin_href = HREF_GPIO_NUM;
config.pin_sccb_sda = SIOD_GPIO_NUM;
config.pin_sccb_scl = SIOC_GPIO_NUM;
config.pin_pwdn = PWDN_GPIO_NUM;
config.pin_reset = RESET_GPIO_NUM;
config.xclk_freq_hz = 20000000;
config.pixel_format = PIXFORMAT_JPEG;
config.frame_size = FRAMESIZE_VGA; // 640x480
config.jpeg_quality = 12; // 质量(0-63,越小质量越好)
config.fb_count = 1;
config.grab_mode = CAMERA_GRAB_LATEST;
esp_err_t err = esp_camera_init(&config);
if (err != ESP_OK) {
Serial.printf("摄像头初始化失败: 0x%x\n", err);
return false;
}
return true;
}
void connectToWiFi() {
if (WiFi.status() == WL_CONNECTED) {
return;
}
Serial.printf("连接WiFi: %s", ssid);
WiFi.begin(ssid, password);
int attempts = 0;
while (WiFi.status() != WL_CONNECTED && attempts < 20) {
delay(500);
Serial.print(".");
attempts++;
}
if (WiFi.status() == WL_CONNECTED) {
Serial.println("\nWiFi连接成功");
Serial.print("IP地址: ");
Serial.println(WiFi.localIP());
} else {
Serial.println("\nWiFi连接失败");
}
}
void sendFrameToServer(camera_fb_t* fb) {
if (!fb || fb->len == 0) {
Serial.println("错误:摄像头帧数据无效");
failedCount++;
return;
}
HTTPClient http;
http.begin(serverUrl);
// 使用HTTPClient的简化接口发送数据
http.addHeader("Content-Type", "image/jpeg");
http.addHeader("X-Device-ID", "esp32-s3-cam");
http.addHeader("X-Frame-Size", String(fb->len));
http.addHeader("X-Frame-Number", String(frameCount));
int httpCode = http.POST(fb->buf, fb->len);
if (httpCode == HTTP_CODE_OK) {
frameCount++;
String response = http.getString();
Serial.printf("✓ 帧 #%d: %d字节\n", frameCount, fb->len);
// 简单打印服务器响应
if (response.length() > 0) {
Serial.print(" 服务器: ");
Serial.println(response);
}
failedCount = 0;
} else {
Serial.printf("✗ HTTP错误: %d", httpCode);
if (httpCode < 0) {
Serial.printf(" (%s)", http.errorToString(httpCode).c_str());
} else {
String response = http.getString();
if (response.length() > 0) {
Serial.print(" | ");
Serial.print(response);
}
}
Serial.println();
failedCount++;
}
http.end();
// 如果连续失败次数过多,重启
if (failedCount >= 10) {
Serial.printf("连续失败次数过多,重启系统...%d\n",failedCount);
//ESP.restart();
delay(10000);
}
}
void loop() {
// 检查WiFi连接
if (WiFi.status() != WL_CONNECTED) {
Serial.println("WiFi断开,重新连接...");
connectToWiFi();
delay(2000);
return;
}
// 检查摄像头
if (!cameraInitialized) {
Serial.println("摄像头未初始化");
delay(1000);
return;
}
// 控制帧率
unsigned long currentTime = millis();
if (currentTime - lastFrameTime < frameInterval) {
delay(1);
return;
}
lastFrameTime = currentTime;
// 获取摄像头帧
camera_fb_t* fb = esp_camera_fb_get();
if (!fb) {
Serial.println("摄像头捕获失败");
failedCount++;
delay(100);
return;
}
// 发送帧到服务器
sendFrameToServer(fb);
// 释放帧缓冲区
esp_camera_fb_return(fb);
// 每10帧显示一次状态
if (frameCount % 10 == 0) {
Serial.printf("已发送 %d 帧,失败 %d 次\n", frameCount, failedCount);
}
}
二、GO服务关键代码
go
package controller
import (
"encoding/binary"
"fmt"
"github.com/gin-gonic/gin"
"io"
"iot-cloud/internal/common"
"log"
"net/http"
"os"
"path/filepath"
"sync"
"time"
)
var (
recordingsDir = "./logs/mp4" // 录制文件存放目录
currentFile *os.File // 当前文件
fileMu sync.Mutex // 文件锁
frameCount = 0 // 总帧数
aviFrames = 0 // 当前AVI帧数
maxFrames = 300 // 每个AVI最多300帧
fps = 10 // 帧率
width = 640 // 视频宽度
height = 480 // 视频高度
aviHeaderSize = 0 // AVI头大小
moviPos int64 // movi列表开始位置
indexSize = 0 // 索引大小
)
type StreamController struct {
*common.BaseController
}
func NewStreamController() *StreamController {
return &StreamController{
BaseController: &common.BaseController{},
}
}
// RegisterRoutes 注册路由
func (s *StreamController) RegisterRoutes(router *gin.RouterGroup) {
router.POST("", s.HandleStream)
}
func (s *StreamController) HandleStream(c *gin.Context) {
// 读取JPEG数据
data, err := io.ReadAll(c.Request.Body)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"error": "读取失败: " + err.Error(),
})
return
}
// 检查数据
if len(data) < 100 {
c.JSON(http.StatusBadRequest, gin.H{
"error": "数据太小",
})
return
}
// 检查JPEG头
if len(data) < 2 || data[0] != 0xFF || data[1] != 0xD8 {
// 记录但继续处理
fmt.Printf("警告: 帧 #%d 不是有效的JPEG\n", frameCount+1)
}
frameCount++
// 每10帧显示一次
if frameCount%10 == 0 {
fmt.Printf("[%s] 收到帧 #%d: %d字节\n",
time.Now().Format("15:04:05"), frameCount, len(data))
}
fileMu.Lock()
defer fileMu.Unlock()
// 创建新AVI文件
if currentFile == nil {
createNewAVI()
}
// 写入AVI帧
if currentFile != nil {
// 写入'00db' chunk
currentFile.Write([]byte("00db"))
// 写入chunk大小
chunkSize := uint32(len(data))
binary.Write(currentFile, binary.LittleEndian, chunkSize)
// 写入JPEG数据
currentFile.Write(data)
// 如果需要填充,添加1个0字节
if chunkSize%2 != 0 {
currentFile.Write([]byte{0})
}
aviFrames++
// 每maxFrames帧保存为新文件
if aviFrames >= maxFrames {
finalizeCurrentAVI()
createNewAVI()
}
}
c.JSON(http.StatusOK, gin.H{
"status": "success",
"frame": frameCount,
"avi_frame": aviFrames,
})
}
func createNewAVI() {
// 关闭当前文件
if currentFile != nil {
finalizeCurrentAVI()
currentFile.Close()
}
// 创建新文件名
timestamp := time.Now().Format("20060102_150405")
filename := fmt.Sprintf("video_%s.avi", timestamp)
filepath := filepath.Join(recordingsDir, filename)
// 创建文件
file, err := os.Create(filepath)
if err != nil {
log.Printf("创建文件失败: %v", err)
currentFile = nil
return
}
currentFile = file
aviFrames = 0
// 写入AVI头
writeAVIHeader()
fmt.Printf("创建新视频: %s\n", filename)
}
func writeAVIHeader() {
if currentFile == nil {
return
}
// 记录文件开始位置
startPos, _ := currentFile.Seek(0, io.SeekCurrent)
aviHeaderSize = int(startPos)
// RIFF头
currentFile.Write([]byte("RIFF"))
// 文件大小,稍后填充
binary.Write(currentFile, binary.LittleEndian, uint32(0))
currentFile.Write([]byte("AVI "))
// LIST hdrl
currentFile.Write([]byte("LIST"))
binary.Write(currentFile, binary.LittleEndian, uint32(192)) // hdrl大小
currentFile.Write([]byte("hdrl"))
// avih 主AVI头
currentFile.Write([]byte("avih"))
binary.Write(currentFile, binary.LittleEndian, uint32(56)) // 结构大小
binary.Write(currentFile, binary.LittleEndian, uint32(1000000/uint32(fps))) // 微秒每帧
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 最大字节每秒
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 填充
binary.Write(currentFile, binary.LittleEndian, uint32(0x10)) // 标志
binary.Write(currentFile, binary.LittleEndian, uint32(maxFrames)) // 总帧数
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 初始帧
binary.Write(currentFile, binary.LittleEndian, uint32(1)) // 流数量
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 建议缓冲区大小
binary.Write(currentFile, binary.LittleEndian, uint32(width)) // 宽度
binary.Write(currentFile, binary.LittleEndian, uint32(height)) // 高度
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 保留
binary.Write(currentFile, binary.LittleEndian, uint32(0))
binary.Write(currentFile, binary.LittleEndian, uint32(0))
binary.Write(currentFile, binary.LittleEndian, uint32(0))
// LIST strl
currentFile.Write([]byte("LIST"))
binary.Write(currentFile, binary.LittleEndian, uint32(116)) // strl大小
currentFile.Write([]byte("strl"))
// strh 流头
currentFile.Write([]byte("strh"))
binary.Write(currentFile, binary.LittleEndian, uint32(56)) // 结构大小
currentFile.Write([]byte("vids"))
currentFile.Write([]byte("MJPG"))
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 标志
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 优先级
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 初始帧
binary.Write(currentFile, binary.LittleEndian, uint32(1)) // 时间刻度
binary.Write(currentFile, binary.LittleEndian, uint32(fps)) // 采样率
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 开始时间
binary.Write(currentFile, binary.LittleEndian, uint32(maxFrames)) // 长度
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 建议缓冲区大小
binary.Write(currentFile, binary.LittleEndian, uint32(0xFFFFFFFF)) // 质量 (-1)
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 样本大小
binary.Write(currentFile, binary.LittleEndian, uint16(0)) // 左
binary.Write(currentFile, binary.LittleEndian, uint16(0)) // 上
binary.Write(currentFile, binary.LittleEndian, uint16(uint16(width))) // 右
binary.Write(currentFile, binary.LittleEndian, uint16(uint16(height))) // 下
// strf 流格式
currentFile.Write([]byte("strf"))
binary.Write(currentFile, binary.LittleEndian, uint32(40)) // 结构大小
binary.Write(currentFile, binary.LittleEndian, uint32(40)) // BITMAPINFOHEADER大小
binary.Write(currentFile, binary.LittleEndian, uint32(width)) // 宽度
binary.Write(currentFile, binary.LittleEndian, uint32(height)) // 高度
binary.Write(currentFile, binary.LittleEndian, uint16(1)) // 平面数
binary.Write(currentFile, binary.LittleEndian, uint16(24)) // 位深度
currentFile.Write([]byte("MJPG"))
binary.Write(currentFile, binary.LittleEndian, uint32(width*height*3)) // 图像大小
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 水平分辨率
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 垂直分辨率
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 使用颜色数
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 重要颜色数
// LIST movi
currentFile.Write([]byte("LIST"))
// 保存movi大小位置
moviPos, _ = currentFile.Seek(0, io.SeekCurrent)
binary.Write(currentFile, binary.LittleEndian, uint32(0)) // 稍后填充
currentFile.Write([]byte("movi"))
// 记录aviHeaderSize
endPos, _ := currentFile.Seek(0, io.SeekCurrent)
aviHeaderSize = int(endPos)
}
func finalizeCurrentAVI() {
if currentFile == nil || aviFrames == 0 {
return
}
// 保存当前位置
currentPos, _ := currentFile.Seek(0, io.SeekCurrent)
// 更新RIFF头中的文件大小
currentFile.Seek(4, io.SeekStart)
fileSize := uint32(currentPos - 8)
binary.Write(currentFile, binary.LittleEndian, fileSize)
// 更新avih头中的总帧数
currentFile.Seek(0x38, io.SeekStart)
binary.Write(currentFile, binary.LittleEndian, uint32(aviFrames))
// 更新strh中的长度
currentFile.Seek(0x9C, io.SeekStart)
binary.Write(currentFile, binary.LittleEndian, uint32(aviFrames))
// 更新movi列表大小
moviSize := currentPos - moviPos - 4
currentFile.Seek(moviPos, io.SeekStart)
binary.Write(currentFile, binary.LittleEndian, uint32(moviSize))
// 写idx1索引
currentFile.Seek(currentPos, io.SeekStart)
// 计算索引大小
indexSize = aviFrames * 16
currentFile.Write([]byte("idx1"))
binary.Write(currentFile, binary.LittleEndian, uint32(indexSize))
fmt.Printf("视频完成: %d帧\n", aviFrames)
}
三、保存的AVI文件

四、Arduino打印的日志

五、Go打印的日志
