esp32s3 + ov2640,给摄像头加上拍照功能,存储到sd卡

前面已经做了从过网络访问摄像头的实验:esp32s3 通过wifi查看正点原子摄像头-优快云博客

现在的目标是添加一张sd卡(其实是手机上用的tf卡),没有现成的,就从玩具中拆了一张出来,一看才128M,这也太小了,玩具厂可真会节省成本^_^!

功能上,就添加了sd卡的读写功能,还在页面上添加了一个查看sd卡中存储的照片的功能。

就直接在main.cpp上添加代码了,实际项目中的话,最好还是拆出来,这样代码比较清晰。

代码如下:

#include <Arduino.h>
#include "esp_camera.h"
#include "camera.h"
#include "xl9555.h"
#include <WiFi.h>
#include <WebServer.h>
#include <SD.h>          // 使用SPI模式需要包含SD库
#include <SPI.h>         // SPI库
#include "FS.h"          // 文件系统支持

// SD卡引脚定义(自定义以避免与摄像头冲突)
#define SD_CS_PIN         2   
#define SD_MISO_PIN       13 
#define SD_MOSI_PIN       11
#define SD_SCK_PIN        12

// SD卡相关变量
bool sdCardAvailable = false;
uint32_t imageCounter = 0;  // 图片计数器

// WiFi配置
const char* ssid = "改为你家的wifi";
const char* password = "改为你家的wifi密码";

// HTTP服务器
WebServer server(80);

camera_fb_t *fb = NULL;

// 提前声明HTTP处理函数
void handleRoot();
void handleStream();
void handleJPG();
void handleListImages();  // 查看SD卡照片
void handleImageFile();   // 显示单张图片

// 函数声明
bool saveImageToSD(camera_fb_t *fb);
bool initSDCard();

void setup() {
  Serial.begin(115200);

  // 初始化SD卡
  sdCardAvailable = initSDCard();
  
  // 初始化XL9555扩展IO
  xl9555_init();
  
  // 摄像头初始化
  if(camera_init() != 0) {
    Serial.println("摄像头初始化失败!");
    while(1) delay(100);
  }
  
  // 连接WiFi
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
    delay(500);
    Serial.print(".");
  }
  Serial.println("\nWiFi已连接");
  Serial.print("IP地址: ");
  Serial.println(WiFi.localIP());

  // 设置HTTP路由
  server.on("/", HTTP_GET, handleRoot);
  server.on("/stream", HTTP_GET, handleStream);
  server.on("/jpg", HTTP_GET, handleJPG);
  server.on("/list", HTTP_GET, handleListImages); // 查看照片列表
  server.on("/image", HTTP_GET, handleImageFile); // 显示单张图片
  
  server.begin();
  Serial.println("HTTP服务器已启动");
}

void loop() {
  server.handleClient();
}

/**
 * @brief       摄像头初始化
 * @param       无
 * @retval      0:成功 / 1:失败 
 */
uint8_t camera_init(void) {
  camera_config_t camera_config;

  // 引脚配置
  camera_config.pin_d0 = OV_D0_PIN;
  camera_config.pin_d1 = OV_D1_PIN;
  camera_config.pin_d2 = OV_D2_PIN;
  camera_config.pin_d3 = OV_D3_PIN;
  camera_config.pin_d4 = OV_D4_PIN;
  camera_config.pin_d5 = OV_D5_PIN;
  camera_config.pin_d6 = OV_D6_PIN;
  camera_config.pin_d7 = OV_D7_PIN;
  camera_config.pin_xclk = OV_XCLK_PIN;
  camera_config.pin_pclk = OV_PCLK_PIN;
  camera_config.pin_vsync = OV_VSYNC_PIN;
  camera_config.pin_href = OV_HREF_PIN;
  camera_config.pin_sccb_sda = OV_SDA_PIN;
  camera_config.pin_sccb_scl = OV_SCL_PIN;
  camera_config.pin_pwdn = OV_PWDN_PIN;
  camera_config.pin_reset = OV_RESET_PIN;
  
  // 其他配置
  camera_config.ledc_channel = LEDC_CHANNEL_0;
  camera_config.ledc_timer = LEDC_TIMER_0;
  camera_config.xclk_freq_hz = 20000000;
  camera_config.pixel_format = PIXFORMAT_JPEG;  // 使用JPEG格式
  
  // 优先使用PSRAM
  if(psramFound()){
    camera_config.frame_size = FRAMESIZE_SVGA;  // 800x600
    camera_config.jpeg_quality = 12;
    camera_config.fb_count = 2;
    camera_config.grab_mode = CAMERA_GRAB_LATEST;
    camera_config.fb_location = CAMERA_FB_IN_PSRAM;
  } else {
    camera_config.frame_size = FRAMESIZE_QVGA;  // 320x240
    camera_config.jpeg_quality = 15;
    camera_config.fb_count = 1;
  }

  // XL9555引脚控制
  if (OV_PWDN_PIN == -1) {
    xl9555_io_config(OV_PWDN, IO_SET_OUTPUT);
    xl9555_pin_set(OV_PWDN, IO_SET_LOW);
  }
  if (OV_RESET_PIN == -1) {
    xl9555_io_config(OV_RESET, IO_SET_OUTPUT);
    xl9555_pin_set(OV_RESET, IO_SET_LOW);
    delay(20);
    xl9555_pin_set(OV_RESET, IO_SET_HIGH);
    delay(20);
  }

  // 初始化摄像头
  esp_err_t err = esp_camera_init(&camera_config);
  if (err != ESP_OK) {
    Serial.printf("摄像头初始化失败: 0x%x", err);
    return 1;
  }

  // 摄像头传感器配置
  sensor_t *s = esp_camera_sensor_get();
  
  // 根据摄像头型号设置方向
  if (s->id.PID == OV2640_PID) {
    s->set_vflip(s, 0);       // OV2640不需要垂直翻转
  } else {
    s->set_vflip(s, 1);       // 其他摄像头垂直翻转
  }
  
  // 图像参数调整
  s->set_brightness(s, 0);    // 亮度 (-2~2)
  s->set_contrast(s, 0);      // 对比度 (-2~2)
  s->set_saturation(s, 0);    // 饱和度 (-2~2)
  s->set_hmirror(s, 0);       // 水平镜像

  Serial.println("摄像头初始化成功");
  return 0;
}

// 根页面处理
void handleRoot() {
  String html = "<html>\n"
    "<head>\n"
    "<meta charset=\"UTF-8\">\n"  // 添加UTF-8编码声明
    "<title>ESP32-CAM 监控</title>\n"
    "<meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n"
    "<style>\n"
    "body { font-family: Arial; text-align: center; margin: 0; padding: 20px; background-color: #f5f5f5; }\n"
    "h1 { color: #333; }\n"
    ".container { max-width: 800px; margin: 0 auto; }\n"
    "img { max-width: 100%; border: 1px solid #ddd; border-radius: 4px; box-shadow: 0 0 10px rgba(0,0,0,0.1); }\n"
    ".controls { margin: 20px 0; }\n"
    "a { display: inline-block; margin: 10px; padding: 10px 20px; background: #4CAF50; color: white; text-decoration: none; border-radius: 4px; }\n"
    ".gallery { display: flex; flex-wrap: wrap; justify-content: center; }\n"
    ".gallery img { width: 150px; height: 150px; object-fit: cover; margin: 5px; }\n"
    "</style>\n"
    "</head>\n"
    "<body>\n"
    "<div class=\"container\">\n"
    "<h1>ESP32-S3 摄像头监控</h1>\n"
    "<img src=\"/stream\" id=\"video\" alt=\"实时视频流\">\n"
    "<div class=\"controls\">\n"
    "<a href=\"/jpg\">拍照</a>\n"
    "<a href=\"/list\">查看保存的照片</a>\n"
    "<a href=\"javascript:location.reload()\">刷新</a>\n"
    "</div>\n"
    "<p>IP地址: " + WiFi.localIP().toString() + "</p>\n"
    "</div>\n"
    "<script>\n"
    "// Auto-refresh image\n"
    "setInterval(function() {\n"
    "  document.getElementById('video').src = '/stream?' + Date.now();\n"
    "}, 100);\n"
    "</script>\n"
    "</body>\n"
    "</html>\n";
  
  server.send(200, "text/html", html);
}

// 视频流处理
void handleStream() {
  WiFiClient client = server.client();
  
  // 发送HTTP头
  String response = "HTTP/1.1 200 OK\r\n";
  response += "Content-Type: multipart/x-mixed-replace; boundary=frame\r\n\r\n";
  client.print(response);
  
  while (client.connected()) {
    fb = esp_camera_fb_get();
    if (!fb) {
      Serial.println("摄像头捕获失败");
      delay(100);
      continue;
    }
    
    // 发送图像边界
    String header = "--frame\r\n";
    header += "Content-Type: image/jpeg\r\n";
    header += "Content-Length: " + String(fb->len) + "\r\n\r\n";
    client.print(header);
    
    // 发送图像数据
    client.write(fb->buf, fb->len);
    
    esp_camera_fb_return(fb);
    fb = NULL;
    
    // 短暂延迟以控制帧率
    delay(50);
    
    // 检查连接状态
    if (!client.connected()) {
      break;
    }
  }
}

// 单张图片处理
void handleJPG() {
  fb = esp_camera_fb_get();
  if (!fb) {
    server.send(500, "text/plain", "摄像头捕获失败");
    return;
  }
  
  // 保存图片到SD卡
  if (sdCardAvailable) {
    saveImageToSD(fb);
  }
  
  // 发送图片到客户端
  server.send_P(
    200, 
    "image/jpeg", 
    reinterpret_cast<const char*>(fb->buf), 
    fb->len
  );
  
  esp_camera_fb_return(fb);
  fb = NULL;
}

// 保存图片到SD卡
bool saveImageToSD(camera_fb_t *fb) {
  if (!sdCardAvailable) {
    Serial.println("SD卡不可用,无法保存图像");
    return false;
  }
  
  // 创建目录(如果不存在)
  if (!SD.exists("/images")) {
    if (!SD.mkdir("/images")) {
      Serial.println("创建/images目录失败");
      return false;
    }
    Serial.println("已创建/images目录");
  }
  
  // 生成带时间戳的文件名
  struct timeval tv;
  gettimeofday(&tv, NULL);
  char filename[64];
  snprintf(filename, sizeof(filename), "/images/img_%lu_%lu.jpg", tv.tv_sec, imageCounter++);
  
  // 打开文件
  File file = SD.open(filename, FILE_WRITE);
  if (!file) {
    Serial.printf("无法创建文件: %s\n", filename);
    return false;
  }
  
  // 写入图像数据
  size_t bytesWritten = file.write(fb->buf, fb->len);
  file.close();
  
  if (bytesWritten != fb->len) {
    Serial.printf("写入不完整: %d/%d 字节\n", bytesWritten, fb->len);
    return false;
  }
  
  Serial.printf("图片已保存: %s (%d字节)\n", filename, bytesWritten);
  return true;
}

// 初始化SD卡 (SPI模式)
bool initSDCard() {
  Serial.println("初始化SD卡(SPI模式)...");
  
  // 初始化SPI引脚
  SPI.begin(SD_SCK_PIN, SD_MISO_PIN, SD_MOSI_PIN, SD_CS_PIN);
  
  if (!SD.begin(SD_CS_PIN)) {
    Serial.println("SD卡初始化失败");
    return false;
  }
  
  // 检查SD卡类型
  uint8_t cardType = SD.cardType();
  if (cardType == CARD_NONE) {
    Serial.println("未检测到SD卡");
    return false;
  }
  
  Serial.print("SD卡类型: ");
  if (cardType == CARD_MMC) {
    Serial.println("MMC");
  } else if (cardType == CARD_SD) {
    Serial.println("SDSC");
  } else if (cardType == CARD_SDHC) {
    Serial.println("SDHC");
  } else {
    Serial.println("未知");
  }
  
  // 显示SD卡大小
  uint64_t cardSize = SD.cardSize() / (1024 * 1024);
  Serial.printf("SD卡大小: %lluMB\n", cardSize);
  
  // 检查可用空间
  uint64_t freeBytes = SD.totalBytes() - SD.usedBytes();
  Serial.printf("可用空间: %lluMB\n", freeBytes / (1024 * 1024));
  
  // 确保/images目录存在
  if (!SD.exists("/images")) {
    if (!SD.mkdir("/images")) {
      Serial.println("无法创建/images目录");
    } else {
      Serial.println("已创建/images目录");
    }
  }
  
  return true;
}

// 查看SD卡照片列表
void handleListImages() {
  if (!sdCardAvailable) {
    server.send(200, "text/plain", "SD卡不可用");
    return;
  }
  
  String html = "<html><head><meta charset=\"UTF-8\"><title>已保存的照片</title><style>";
  html += "body { text-align: center; font-family: Arial; }";
  html += "h1 { color: #333; }";
  html += ".gallery { display: flex; flex-wrap: wrap; justify-content: center; }";
  html += ".gallery a { margin: 10px; text-decoration: none; color: #333; }";
  html += ".gallery img { width: 200px; height: 150px; object-fit: cover; border: 1px solid #ddd; border-radius: 4px; }";
  html += ".back-btn { display: block; margin: 20px auto; padding: 10px 20px; background: #4CAF50; color: white; border-radius: 4px; width: fit-content; }";
  html += "</style></head><body>";
  html += "<h1>已保存的照片</h1>";
  html += "<div class='gallery'>";
  
  // 列出/images目录下的文件
  File root = SD.open("/images");
  if (!root) {
    html += "<p>无法打开目录</p>";
  } else if (!root.isDirectory()) {
    html += "<p>不是一个目录</p>";
  } else {
    File file = root.openNextFile();
    int count = 0;
    while (file) {
      if (!file.isDirectory()) {
        String path = file.path();
        if (path.endsWith(".jpg") || path.endsWith(".jpeg")) {
          // 移除"/sd"前缀,因为SD库会添加这个前缀
          if (path.startsWith("/sd")) {
            path = path.substring(3);
          }
          
          html += "<a href='/image?path=" + path + "'>";
          html += "<img src='/image?path=" + path + "' alt='照片'>";
          html += "<br>" + path.substring(8) + "</a>"; // 显示文件名(去掉/images/前缀)
          count++;
        }
      }
      file = root.openNextFile();
    }
    
    if (count == 0) {
      html += "<p>没有找到照片</p>";
    }
  }
  root.close();
  
  html += "</div>";
  html += "<a class='back-btn' href='/'>返回主页</a>";
  html += "</body></html>";
  
  server.send(200, "text/html", html);
}

// 显示单张图片
void handleImageFile() {
  String path = server.arg("path");
  
  if (path.length() == 0) {
    server.send(400, "text/plain", "缺少路径参数");
    return;
  }
  
  // 确保路径以/开头
  if (!path.startsWith("/")) {
    path = "/" + path;
  }
  
  // 检查文件是否存在
  if (!SD.exists(path)) {
    server.send(404, "text/plain", "文件未找到: " + path);
    return;
  }
  
  File file = SD.open(path, FILE_READ);
  if (!file) {
    server.send(500, "text/plain", "无法打开文件: " + path);
    return;
  }
  
  // 设置正确的Content-Type
  server.setContentLength(file.size());
  server.send(200, "image/jpeg");
  
  // 流式传输文件内容
  uint8_t buffer[1024];
  size_t bytesRead;
  while ((bytesRead = file.read(buffer, sizeof(buffer))) > 0) {
    server.sendContent_P((const char*)buffer, bytesRead);
  }
  
  file.close();
}

platformio.ini

[env:dnesp32s3]
platform = espressif32
board = dnesp32s3
framework = arduino
monitor_speed = 115200
test_speed = 115200
upload_speed=115200
debug_speed = 115200

lib_deps = 
	esp32-camera@^2.0.0
    https://github.com/me-no-dev/AsyncTCP.git
    https://github.com/me-no-dev/ESPAsyncWebServer.git

; 启用PSRAM支持
board_build.psram = opi
board_build.psram_mode = opi

; 分区方案
board_build.partitions = default_16MB.csv

; 优化设置
build_flags = 
    -DBOARD_HAS_PSRAM
    -mfix-esp32-psram-cache-issue

 

烧录成功:

 从页面上查看实际效果:

 尝试拍照:

 点击“查看保存的照片”:

不过页面刷了一会才出来,需要耐心等几秒钟。

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

FightingFreedom

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值