ESP32-S3+OV2640简单推流到GO服务

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打印的日志

相关推荐
BrianGriffin2 小时前
asdf 安装的 PHP 上传文件大小限制
开发语言·php
码luffyliu2 小时前
从 2 小时价格轮询任务通知丢失,拆解 Go Context 生命周期管控核心
后端·golang·go
a努力。3 小时前
宇树Java面试被问:方法区、元空间的区别和演进
java·后端·面试·宇树科技
2501_916766543 小时前
【面试题1】128陷阱、==和equals的区别
java·开发语言
码事漫谈3 小时前
二叉树中序遍历:递归与非递归实现详解
后端
码事漫谈3 小时前
跨越进程的对话之从管道到gRPC的通信技术演进
后端
a程序小傲3 小时前
蚂蚁Java面试被问:注解的工作原理及如何自定义注解
java·开发语言·python·面试
似水এ᭄往昔4 小时前
【C++】--封装红⿊树实现mymap和myset
开发语言·数据结构·c++·算法·stl
charlie1145141914 小时前
嵌入式现代C++教程:C++98——从C向C++的演化(3)
c语言·开发语言·c++·笔记·学习·嵌入式