ESP32-CAM 網路影像串流,初學者完整教學(SD 卡存檔)

ESP32-CAM 不只能串流!一鍵拍照、手機直接下載,還能自動存到 SD 卡,檔名自動編號不怕覆蓋。完整程式碼加詳細註解,初學者也能輕鬆上手。

ESP32-CAM 網路影像串流,初學者完整教學(SD 卡存檔)

本系列前兩篇教學,大家已經學會監看串流影像,拍單張影像,並存到電腦或手機。這篇要介紹的,就是把拍到的單張照片,存到ESP32-CAM內建的SD中。如此一來,有關ESP32-CAM的基本操作,大家都可以搞定了!

ESP32-CAM開發板 帶OV2640攝影模組 可選購上傳底板
ESP32-CAM開發板 帶OV2640攝影模組 本商品為相容版本,請注意哦! ※ 32位元CPU,可作應用處理器 ※ 主頻高達240MHz,運算能力高達 600 DMIPS ※ 內置 520 KB SRAM,外置4M PSRAM ※ 支持UART/SPI/I2C/PWM/ADC/DAC等接口 ※ 支持OV2640和OV7670攝像頭,內置閃光燈 ※ 支持圖片WiFi上傳 ※ 支持TF卡 ※ 支持多種休眠模式 ※ 內嵌Lwip和FreeRTOS ※ 支持 STA/AP/STA+AP 工作模式 ※

本篇完成後,把 ESP32-CAM 連上 Wi-Fi ,用手機或電腦的瀏覽器就能:

  1. 即時串流 — 低畫質(320×240)流暢播放攝影機畫面
  2. 一鍵拍照 — 自動切換到 VGA(640×480)拍一張清晰照片
  3. 下載照片 — 拍完後在瀏覽器預覽,按按鈕下載到手機或電腦
  4. 存到 SD 卡 — 一鍵把照片寫入 ESP32-CAM 板上的 SD 卡,檔名自動編號不會覆蓋

不需要安裝任何 App,也不需要額外的 SD 卡模組(板子上已經內建)。

如果你尚未看過前兩篇,傑森強烈建議先看一下哦!

ESP32-CAM 網路影像串流,初學者完整教學
把 ESP32-CAM 開發板連上你家的 Wi-Fi,然後用手機或電腦的瀏覽器,就能即時看到攝影機的畫面。不需要安裝任何 App,只要在同一個 Wi-Fi 底下,打開網頁就能看。
ESP32-CAM 網路影像串流,初學者完整教學(拍照篇)
有了ESP32-CAM開發板,就能做出一台網路攝影機!但我們不只要即時串流,還要一鍵高畫質拍照、手機直接下載!完整程式碼不到 200 行,初學者也能 10 分鐘上手。

關於 SD 卡

ESP32-CAM(AI-Thinker)板上已經內建 Micro SD 卡插槽,不需要額外接線或外接模組。SD 卡在使用前必須格式化為 FAT32 格式。Windows 可以直接右鍵格式化;Mac 使用磁碟工具程式。

注意:SD 卡不是必須的。 沒有插卡一樣可以使用串流和下載功能,只是「存到 SD 卡」按鈕會提示未偵測到卡片。


一、程式碼

有關Arduino IDE的準備,還有基本的串流功能,我們這篇就不重複說明了,請需要了解的人前往前兩篇看看哦,這邊我們直就接進入程式的說明囉!

ESP32-CAM 網路影像串流,初學者完整教學
把 ESP32-CAM 開發板連上你家的 Wi-Fi,然後用手機或電腦的瀏覽器,就能即時看到攝影機的畫面。不需要安裝任何 App,只要在同一個 Wi-Fi 底下,打開網頁就能看。
ESP32-CAM 網路影像串流,初學者完整教學(拍照篇)
有了ESP32-CAM開發板,就能做出一台網路攝影機!但我們不只要即時串流,還要一鍵高畫質拍照、手機直接下載!完整程式碼不到 200 行,初學者也能 10 分鐘上手。

程式架構說明

這支程式在 ESP32-CAM 上同時運行兩個 HTTP 伺服器:

Port 80(網頁伺服器) 處理五個網址路徑:/ 首頁顯示串流畫面和拍照按鈕;/capture 拍照 API;/preview 預覽頁面;/photo 回傳照片資料;/save_sd 把照片存到 SD 卡。

Port 81(串流伺服器) 只處理 /stream,用 MJPEG 格式持續傳送畫面。

串流與拍照的解析度切換

平時串流維持在 QVGA(320×240)確保流暢,按下拍照時透過 setCameraParams() 切換到 VGA(640×480)拍一張照片,拍完再切回 QVGA。OV2640 初始化時必須用最大解析度,之後才能動態切換到較小的解析度。

拍照流程的三個關鍵處理

拍照不是單純呼叫一次 esp_camera_fb_get() 就好。程式做了三件事:切換解析度後等 300 毫秒讓攝影機穩定;連續拍 3 幀丟掉讓自動曝光調整好(避免偏綠或過亮);用 ps_malloc() 把照片複製到 PSRAM 暫存(攝影機的 frame buffer 歸還後資料會被覆蓋)。

SD 卡存檔機制

SD 卡使用 1-bit MMC 模式掛載,比 4-bit 穩定且不會佔用閃光燈的 GPIO4。開機時自動掃描已有的照片編號,每次存檔前用 SD_MMC.exists() 即時檢查檔名是否已存在,確保不會覆蓋舊檔。檔名格式為 photo_001.jpgphoto_002.jpg 自動遞增。

避免 crash 的設計

所有 HTML 字串用 static const 宣告放在全域記憶體而非 stack,同時把 httpd 的 stack_size 從預設 4096 加大到 8192,雙重保險避免 stack overflow。

完整程式碼

將以下程式碼貼到 Arduino IDE,只需修改最上方的 Wi-Fi 名稱和密碼。

#include "esp_camera.h"    // ESP32 攝影機函式庫
#include <WiFi.h>           // Wi-Fi 連線函式庫
#include "esp_http_server.h" // HTTP 伺服器函式庫
#include "FS.h"             // 檔案系統函式庫
#include "SD_MMC.h"         // SD 卡函式庫(ESP32-CAM 用 MMC 模式)

// ===== 請改成你自己的 Wi-Fi 名稱和密碼 =====
const char* ssid = "你的WiFi名稱";
const char* password = "你的WiFi密碼";

// ============================================================
//  攝影機設定區(日後要調整只改這裡就好)
// ============================================================

// --- 串流設定(低解析度,讓畫面流暢)---
#define STREAM_FRAME_SIZE   FRAMESIZE_QVGA  // 串流解析度 320x240
#define STREAM_QUALITY      12              // 串流 JPEG 品質(0~63,越小越好)

// --- 拍照設定(高解析度,讓照片清晰)---
#define CAPTURE_FRAME_SIZE  FRAMESIZE_VGA   // 拍照解析度 640x480
#define CAPTURE_QUALITY     6               // 拍照 JPEG 品質(VGA 建議 6~10)

// --- 其他設定 ---
#define XCLK_FREQ          10000000         // 攝影機時鐘 10MHz(降低可減少畫面條紋)
#define PHOTO_FILENAME     "esp32cam_photo.jpg"  // 瀏覽器下載時的預設檔名
#define HTTPD_STACK_SIZE    8192            // HTTP 伺服器 stack 大小(預設 4096 太小會 crash)

/*
 * 解析度對照表(替換上面的 FRAMESIZE_XXX):
 *
 *   FRAMESIZE_QVGA   = 320 x 240    ← 串流推薦
 *   FRAMESIZE_CIF    = 400 x 296
 *   FRAMESIZE_VGA    = 640 x 480    ← 拍照目前使用
 *   FRAMESIZE_SVGA   = 800 x 600
 *   FRAMESIZE_XGA    = 1024 x 768
 *   FRAMESIZE_SXGA   = 1280 x 1024
 *   FRAMESIZE_UXGA   = 1600 x 1200  ← 拍照最大(如需更高畫質可改回此值)
 *
 * JPEG 品質建議:
 *   串流用:10~15(檔案小,傳輸快)
 *   拍照用:4~8(畫質好,VGA 約 30~80KB)
 */

// ============================================================
//  AI-Thinker ESP32-CAM 攝影機腳位(固定的,不用改)
// ============================================================
#define PWDN_GPIO_NUM     32
#define RESET_GPIO_NUM    -1
#define XCLK_GPIO_NUM      0
#define SIOD_GPIO_NUM     26
#define SIOC_GPIO_NUM     27
#define Y9_GPIO_NUM       35
#define Y8_GPIO_NUM       34
#define Y7_GPIO_NUM       39
#define Y6_GPIO_NUM       36
#define Y5_GPIO_NUM       21
#define Y4_GPIO_NUM       19
#define Y3_GPIO_NUM       18
#define Y2_GPIO_NUM        5
#define VSYNC_GPIO_NUM    25
#define HREF_GPIO_NUM     23
#define PCLK_GPIO_NUM     22

// ============================================================
//  MJPEG 串流格式定義(固定的,不用改)
// ============================================================
#define PART_BOUNDARY "123456789000000000000987654321"
static const char* _STREAM_CONTENT_TYPE = "multipart/x-mixed-replace;boundary=" PART_BOUNDARY;
static const char* _STREAM_BOUNDARY = "\r\n--" PART_BOUNDARY "\r\n";
static const char* _STREAM_PART = "Content-Type: image/jpeg\r\nContent-Length: %u\r\n\r\n";

// 兩個 HTTP 伺服器:網頁用 port 80,串流用 port 81
httpd_handle_t stream_httpd = NULL;
httpd_handle_t page_httpd = NULL;

// ============================================================
//  拍照暫存區
// ============================================================
//
// 拍照後照片暫存在這裡,直到下次拍照時被覆蓋。
// 使用 PSRAM(外部 4MB 記憶體)儲存,因為內部 SRAM 只有 520KB 太小。
//
uint8_t* captured_buf = NULL;   // 指向照片資料的指標
size_t captured_len = 0;        // 照片資料大小(bytes)

// ============================================================
//  SD 卡相關變數
// ============================================================
//
// ESP32-CAM(AI-Thinker)板上內建 Micro SD 卡插槽,
// 使用 SD_MMC 函式庫以 1-bit MMC 模式存取。
//
// 為什麼用 1-bit 模式?
//   ESP32-CAM 的 SD 卡 4-bit 模式會用到 GPIO4(也就是板載閃光燈的腳位),
//   導致 SD 卡和閃光燈衝突。改用 1-bit 模式只用 GPIO2 和 GPIO14,
//   速度稍慢但更穩定,也不會影響閃光燈。
//
bool sd_ready = false;       // SD 卡是否可用(開機時偵測)
int sd_photo_count = 0;      // 目前照片編號(自動遞增)

// ============================================================
//  工具函式:切換攝影機解析度和品質
// ============================================================
void setCameraParams(framesize_t frameSize, int quality) {
    sensor_t *s = esp_camera_sensor_get();
    s->set_framesize(s, frameSize);
    s->set_quality(s, quality);
}

// ============================================================
//  SD 卡初始化
// ============================================================
//
// 開機時呼叫一次,做三件事:
//   1. 掛載 SD 卡檔案系統
//   2. 確認卡片是否存在
//   3. 掃描已有的照片,找出最大的編號
//
// 掃描編號是為了讓新照片的編號接續下去,
// 例如卡片裡已經有 photo_001.jpg ~ photo_005.jpg,
// 下次存檔就從 photo_006.jpg 開始。
//
void initSDCard() {
    // SD_MMC.begin() 的第二個參數 true 表示使用 1-bit 模式
    if (SD_MMC.begin("/sdcard", true)) {
        uint8_t cardType = SD_MMC.cardType();
        if (cardType != CARD_NONE) {
            sd_ready = true;
            Serial.printf("SD 卡就緒!大小:%llu MB\n",
                           SD_MMC.cardSize() / (1024 * 1024));

            // 掃描已有的照片檔案,找出最大的編號
            File root = SD_MMC.open("/");
            while (File f = root.openNextFile()) {
                String name = f.name();

                // f.name() 在不同版本的 ESP32 Arduino 核心回傳格式不同:
                //   有些回傳 "/photo_001.jpg"(帶斜線)
                //   有些回傳 "photo_001.jpg"(不帶斜線)
                // 所以先統一去掉開頭的斜線
                if (name.startsWith("/")) name = name.substring(1);

                // 照片檔名格式:photo_001.jpg
                if (name.startsWith("photo_") && name.endsWith(".jpg")) {
                    // 取出編號部分:"photo_005.jpg" → "005" → 5
                    int num = name.substring(6, name.length() - 4).toInt();
                    if (num > sd_photo_count) sd_photo_count = num;
                }
                f.close();
            }
            root.close();
            Serial.printf("SD 卡已有 %d 張照片,下一張編號:%d\n",
                           sd_photo_count, sd_photo_count + 1);
        } else {
            Serial.println("SD 卡插槽偵測不到卡片");
        }
    } else {
        Serial.println("SD 卡掛載失敗(沒插卡或格式不是 FAT32)");
    }
}

// ============================================================
//  串流處理(MJPEG 持續傳送畫面給瀏覽器)
// ============================================================
static esp_err_t stream_handler(httpd_req_t *req) {
    camera_fb_t *fb = NULL;
    char part_buf[64];

    esp_err_t res = httpd_resp_set_type(req, _STREAM_CONTENT_TYPE);
    if (res != ESP_OK) return res;

    // 無限迴圈:持續拍照 → 傳送 → 拍照 → 傳送...
    while (true) {
        fb = esp_camera_fb_get();           // 取得一幀 JPEG
        if (!fb) { res = ESP_FAIL; break; }

        size_t hlen = snprintf(part_buf, 64, _STREAM_PART, fb->len);
        res = httpd_resp_send_chunk(req, _STREAM_BOUNDARY, strlen(_STREAM_BOUNDARY));
        if (res == ESP_OK) res = httpd_resp_send_chunk(req, part_buf, hlen);
        if (res == ESP_OK) res = httpd_resp_send_chunk(req, (const char *)fb->buf, fb->len);
        esp_camera_fb_return(fb);           // 歸還 buffer

        if (res != ESP_OK) break;           // 瀏覽器關閉就停止
    }
    return res;
}

// ============================================================
//  拍照 API — /capture
// ============================================================
//
// 流程:釋放舊照片 → 切高畫質 → 丟幾幀穩定曝光 → 正式拍 → 存到 PSRAM → 切回低畫質
//
static esp_err_t capture_handler(httpd_req_t *req) {
    // 釋放舊照片記憶體,避免 memory leak
    if (captured_buf) {
        free(captured_buf);
        captured_buf = NULL;
        captured_len = 0;
    }

    // 切到拍照模式(VGA 640x480 + 高品質)
    setCameraParams(CAPTURE_FRAME_SIZE, CAPTURE_QUALITY);
    delay(300);  // 等攝影機穩定

    // 丟掉前 3 幀,讓自動曝光穩定(避免偏綠或過亮)
    for (int i = 0; i < 3; i++) {
        camera_fb_t *fb = esp_camera_fb_get();
        if (fb) esp_camera_fb_return(fb);
        delay(100);
    }

    // 正式拍一張
    camera_fb_t *fb = esp_camera_fb_get();
    if (!fb) {
        Serial.println("拍照失敗");
        setCameraParams(STREAM_FRAME_SIZE, STREAM_QUALITY);
        httpd_resp_send_500(req);
        return ESP_FAIL;
    }

    // 複製到 PSRAM 暫存(fb 歸還後資料就沒了)
    captured_buf = (uint8_t*)ps_malloc(fb->len);
    if (!captured_buf) captured_buf = (uint8_t*)malloc(fb->len);

    if (captured_buf) {
        memcpy(captured_buf, fb->buf, fb->len);
        captured_len = fb->len;
        Serial.printf("拍照成功!解析度:%dx%d,大小:%u bytes\n",
                       fb->width, fb->height, captured_len);
    } else {
        Serial.println("記憶體不足");
    }

    esp_camera_fb_return(fb);
    setCameraParams(STREAM_FRAME_SIZE, STREAM_QUALITY);  // 切回串流

    httpd_resp_set_type(req, "text/plain");
    httpd_resp_send(req, captured_buf ? "ok" : "fail", captured_buf ? 2 : 4);
    return ESP_OK;
}

// ============================================================
//  照片 API — /photo(顯示或下載)
// ============================================================
//
//   /photo            → 瀏覽器直接顯示圖片(用在預覽頁的 <img>)
//   /photo?download=1 → 瀏覽器跳出另存新檔視窗
//
static esp_err_t photo_handler(httpd_req_t *req) {
    if (!captured_buf || captured_len == 0) {
        httpd_resp_send_404(req);
        return ESP_FAIL;
    }

    // 檢查網址有沒有帶 ?download=1
    char query[32] = {0};
    bool is_download = false;
    if (httpd_req_get_url_query_str(req, query, sizeof(query)) == ESP_OK) {
        char val[4] = {0};
        if (httpd_query_key_value(query, "download", val, sizeof(val)) == ESP_OK) {
            if (strcmp(val, "1") == 0) is_download = true;
        }
    }

    httpd_resp_set_type(req, "image/jpeg");
    if (is_download) {
        // Content-Disposition: attachment 讓瀏覽器跳出下載視窗
        httpd_resp_set_hdr(req, "Content-Disposition",
            "attachment; filename=" PHOTO_FILENAME);
    }
    httpd_resp_send(req, (const char *)captured_buf, captured_len);
    return ESP_OK;
}

// ============================================================
//  存到 SD 卡 API — /save_sd
// ============================================================
//
// 把暫存在 PSRAM 中的照片寫入 SD 卡。
//
// 檔名規則:
//   photo_001.jpg → photo_002.jpg → photo_003.jpg ...
//   每次存檔前用 SD_MMC.exists() 檢查檔名是否已存在,
//   如果已存在就自動跳號,確保絕對不會覆蓋舊照片。
//
// 回傳 JSON 格式讓前端網頁判斷成功或失敗:
//   成功:{"ok":true, "file":"/photo_006.jpg", "size":45678}
//   失敗:{"ok":false, "msg":"no sd card"}
//
static esp_err_t save_sd_handler(httpd_req_t *req) {
    httpd_resp_set_type(req, "application/json");

    // 檢查:有沒有照片可以存?
    if (!captured_buf || captured_len == 0) {
        httpd_resp_send(req, "{\"ok\":false,\"msg\":\"no photo\"}", 28);
        return ESP_OK;
    }

    // 檢查:SD 卡有沒有就緒?
    if (!sd_ready) {
        httpd_resp_send(req, "{\"ok\":false,\"msg\":\"no sd card\"}", 30);
        return ESP_OK;
    }

    // 產生檔名,如果已存在就自動跳號,確保不會覆蓋舊照片
    // 例如卡片裡已有 photo_001 ~ photo_005,
    // 第一次 do-while 就會跳到 photo_006
    char filepath[32];
    do {
        sd_photo_count++;
        snprintf(filepath, sizeof(filepath), "/photo_%03d.jpg", sd_photo_count);
    } while (SD_MMC.exists(filepath));

    // 開啟檔案並寫入 JPEG 資料
    // FILE_WRITE = 建立新檔或覆蓋(但我們已經用 exists() 確認不會覆蓋)
    File file = SD_MMC.open(filepath, FILE_WRITE);
    if (!file) {
        Serial.println("SD 卡寫入失敗:無法開啟檔案");
        sd_photo_count--;  // 失敗就退回編號
        httpd_resp_send(req, "{\"ok\":false,\"msg\":\"write failed\"}", 32);
        return ESP_OK;
    }

    // 把暫存區的照片資料寫入檔案
    size_t written = file.write(captured_buf, captured_len);
    file.close();

    if (written == captured_len) {
        // 寫入成功,回傳檔名和檔案大小
        Serial.printf("已存到 SD 卡:%s(%u bytes)\n", filepath, written);
        char json[100];
        snprintf(json, sizeof(json),
            "{\"ok\":true,\"file\":\"%s\",\"size\":%u}", filepath, written);
        httpd_resp_send(req, json, strlen(json));
    } else {
        // 寫入不完整(可能 SD 卡滿了)
        Serial.println("SD 卡寫入不完整");
        sd_photo_count--;
        httpd_resp_send(req, "{\"ok\":false,\"msg\":\"incomplete write\"}", 36);
    }

    return ESP_OK;
}

// ============================================================
//  首頁 HTML — 串流畫面 + 拍照按鈕
// ============================================================
//
// 注意:用 static const 宣告 HTML 字串,放在全域記憶體而非 stack,
// 避免 httpd 的 stack(8192 bytes)被擠爆導致 crash。
//
static esp_err_t index_handler(httpd_req_t *req) {
    static const char html[] = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>ESP32-CAM 網路串流 - 傑森創工</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#111;color:#eee;font-family:Arial,sans-serif;text-align:center}
h1{font-size:20px;padding:8px 0 2px;color:#e94560}
.sub{font-size:12px;color:#888;padding-bottom:6px}
img{width:100%;max-width:640px;display:block;margin:0 auto;border-radius:6px}
.btn{
    display:inline-block;margin:10px 5px;padding:12px 28px;
    font-size:15px;border:none;border-radius:6px;
    cursor:pointer;color:#fff;background:#0f3460;text-decoration:none;
}
.btn:active{transform:scale(0.95)}
.btn:disabled{background:#333;cursor:not-allowed}
#status{color:#e94560;font-size:13px;margin-top:4px;min-height:20px}
</style>
</head>
<body>
<h1>ESP32-CAM 網路串流</h1>
<p class="sub">傑森創工</p>
<img id="stream" src="">
<div>
    <button class="btn" id="captureBtn" onclick="takePhoto()">📷 拍照</button>
</div>
<p id="status"></p>
<script>
// 頁面載入時,自動開始串流(串流跑在 port 81)
window.onload=function(){
    document.getElementById('stream').src='http://'+location.hostname+':81/stream';
}
// 拍照按鈕:呼叫 /capture API,成功後跳轉到預覽頁
function takePhoto(){
    var btn=document.getElementById('captureBtn');
    var st=document.getElementById('status');
    btn.disabled=true;
    btn.textContent='⏳ 拍照中...';
    st.textContent='正在切換高畫質拍照,請稍候...';
    fetch('/capture')
    .then(function(r){return r.text()})
    .then(function(d){
        if(d==='ok') window.location.href='/preview';
        else{st.textContent='拍照失敗';btn.disabled=false;btn.textContent='📷 拍照';}
    })
    .catch(function(){st.textContent='連線錯誤';btn.disabled=false;btn.textContent='📷 拍照';});
}
</script>
</body>
</html>
)rawliteral";
    httpd_resp_set_type(req, "text/html");
    return httpd_resp_send(req, html, strlen(html));
}

// ============================================================
//  預覽頁面 HTML — 三個按鈕:下載 / 存SD卡 / 返回
// ============================================================
static esp_err_t preview_handler(httpd_req_t *req) {
    static const char html[] = R"rawliteral(
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>照片預覽 - ESP32-CAM</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
body{background:#111;color:#eee;font-family:Arial,sans-serif;text-align:center}
h1{font-size:20px;padding:8px 0 2px;color:#e94560}
.sub{font-size:12px;color:#888;padding-bottom:6px}
img{width:100%;max-width:640px;display:block;margin:0 auto;border-radius:6px}
.btn-row{margin:12px 0;display:flex;justify-content:center;gap:10px;flex-wrap:wrap}
.btn{
    display:inline-block;padding:12px 24px;
    font-size:15px;border:none;border-radius:6px;
    cursor:pointer;color:#fff;text-decoration:none;
}
.btn-download{background:#0f3460}
.btn-sd{background:#e67e22}
.btn-back{background:#555}
.btn:active{transform:scale(0.95)}
#msg{font-size:13px;margin-top:6px;min-height:20px;color:#2ecc71}
</style>
</head>
<body>
<h1>📷 照片預覽</h1>
<p class="sub">傑森創工</p>
<!-- /photo 回傳暫存的 JPEG 照片 -->
<img src="/photo">
<div class="btn-row">
    <!-- download=1 讓瀏覽器跳出下載視窗 -->
    <a class="btn btn-download" href="/photo?download=1">💾 下載照片</a>
    <!-- 呼叫 /save_sd API 把照片寫入 SD 卡 -->
    <button class="btn btn-sd" onclick="saveSD()">📂 存到 SD 卡</button>
    <a class="btn btn-back" href="/">🔙 返回串流</a>
</div>
<p id="msg"></p>
<script>
// 存到 SD 卡:呼叫 /save_sd API,根據回傳的 JSON 顯示結果
function saveSD(){
    var msg=document.getElementById('msg');
    msg.textContent='⏳ 正在寫入 SD 卡...';
    msg.style.color='#f39c12';
    fetch('/save_sd')
    .then(function(r){return r.json()})
    .then(function(d){
        if(d.ok){
            msg.style.color='#2ecc71';
            msg.textContent='✅ 已存到 SD 卡:'+d.file+'('+d.size+' bytes)';
        } else {
            msg.style.color='#e74c3c';
            if(d.msg==='no sd card') msg.textContent='❌ 未偵測到 SD 卡,請確認已插入 FAT32 格式的卡片';
            else if(d.msg==='no photo') msg.textContent='❌ 沒有照片可存,請先拍照';
            else msg.textContent='❌ 寫入失敗:'+d.msg;
        }
    })
    .catch(function(){
        msg.style.color='#e74c3c';
        msg.textContent='❌ 連線錯誤';
    });
}
</script>
</body>
</html>
)rawliteral";
    httpd_resp_set_type(req, "text/html");
    return httpd_resp_send(req, html, strlen(html));
}

// ============================================================
//  setup() — 程式啟動時執行一次
// ============================================================
void setup() {
    Serial.begin(115200);

    // --- 攝影機硬體初始化 ---
    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;

    // 【重要】用拍照的解析度初始化(OV2640 規則:初始化多大,之後最大只能到多大)
    config.xclk_freq_hz = XCLK_FREQ;
    config.pixel_format = PIXFORMAT_JPEG;
    config.frame_size   = CAPTURE_FRAME_SIZE;
    config.jpeg_quality = STREAM_QUALITY;
    config.fb_count     = psramFound() ? 2 : 1;
    config.grab_mode    = CAMERA_GRAB_LATEST;  // 永遠取最新的幀

    if (esp_camera_init(&config) != ESP_OK) {
        Serial.println("攝影機啟動失敗!請檢查排線");
        return;
    }
    setCameraParams(STREAM_FRAME_SIZE, STREAM_QUALITY);  // 切回串流用低解析度
    Serial.println("攝影機啟動成功");

    // --- 初始化 SD 卡 ---
    // SD 卡不是必須的,沒插卡也能用串流和下載功能
    initSDCard();

    // --- 連接 Wi-Fi ---
    WiFi.begin(ssid, password);
    WiFi.setSleep(false);  // 關閉省電模式,串流更穩定
    Serial.print("正在連接 Wi-Fi");
    while (WiFi.status() != WL_CONNECTED) {
        delay(500);
        Serial.print(".");
    }
    Serial.println("\nWi-Fi 已連線!");

    // --- 啟動 Port 80 網頁伺服器(5 個路徑)---
    httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
    cfg.server_port = 80;
    cfg.stack_size = HTTPD_STACK_SIZE;   // 加大 stack 避免 crash
    cfg.max_uri_handlers = 8;

    httpd_uri_t index_uri   = { .uri = "/",        .method = HTTP_GET, .handler = index_handler,   .user_ctx = NULL };
    httpd_uri_t capture_uri = { .uri = "/capture",  .method = HTTP_GET, .handler = capture_handler, .user_ctx = NULL };
    httpd_uri_t preview_uri = { .uri = "/preview",  .method = HTTP_GET, .handler = preview_handler, .user_ctx = NULL };
    httpd_uri_t photo_uri   = { .uri = "/photo",    .method = HTTP_GET, .handler = photo_handler,   .user_ctx = NULL };
    httpd_uri_t save_sd_uri = { .uri = "/save_sd",  .method = HTTP_GET, .handler = save_sd_handler, .user_ctx = NULL };

    if (httpd_start(&page_httpd, &cfg) == ESP_OK) {
        httpd_register_uri_handler(page_httpd, &index_uri);
        httpd_register_uri_handler(page_httpd, &capture_uri);
        httpd_register_uri_handler(page_httpd, &preview_uri);
        httpd_register_uri_handler(page_httpd, &photo_uri);
        httpd_register_uri_handler(page_httpd, &save_sd_uri);
    }

    // --- 啟動 Port 81 串流伺服器 ---
    cfg.server_port = 81;
    cfg.ctrl_port += 1;
    cfg.stack_size = HTTPD_STACK_SIZE;

    httpd_uri_t stream_uri = { .uri = "/stream", .method = HTTP_GET, .handler = stream_handler, .user_ctx = NULL };
    if (httpd_start(&stream_httpd, &cfg) == ESP_OK) {
        httpd_register_uri_handler(stream_httpd, &stream_uri);
    }

    // 印出連線資訊
    Serial.println("=========================================");
    Serial.printf("  開啟網頁:http://%s\n", WiFi.localIP().toString().c_str());
    Serial.printf("  SD 卡狀態:%s\n", sd_ready ? "✅ 就緒" : "❌ 未偵測到");
    Serial.println("=========================================");
}

// loop 裡不需要做任何事,伺服器在背景自動運作
void loop() {
    delay(10000);
}

二、使用方式

上傳程式後,開啟 Serial Monitor(鮑率 115200),按 Reset,正常會看到:

攝影機啟動成功
SD 卡就緒!大小:29620 MB
SD 卡已有 5 張照片,下一張編號:6
正在連接 Wi-Fi..............
Wi-Fi 已連線!
=========================================
  開啟網頁:http://192.168.1.143
  SD 卡狀態:✅ 就緒
=========================================

用瀏覽器開啟網址後,操作流程:


三、SD 卡功能詳解

硬體原理

ESP32-CAM(AI-Thinker)板上已內建 Micro SD 卡插槽。SD 卡透過 SDMMC 介面連接 ESP32,共用部分 GPIO 腳位。本程式使用 1-bit MMC 模式 而非預設的 4-bit 模式,原因是 4-bit 模式會佔用 GPIO4(板載閃光燈的腳位),導致 SD 卡和閃光燈互相衝突。1-bit 模式只用 GPIO2 和 GPIO14,速度略慢但更穩定。

SD 卡初始化流程

開機
 ↓
SD_MMC.begin("/sdcard", true)     ← true = 1-bit 模式
 ↓
成功?─── 否 → sd_ready = false,印出錯誤訊息
 │              (串流和下載功能不受影響)
 是
 ↓
檢查 cardType ─── CARD_NONE → 沒插卡
 │
 有卡
 ↓
sd_ready = true
 ↓
掃描根目錄所有 photo_XXX.jpg
 ↓
找出最大編號 → sd_photo_count
 ↓
下次存檔從 sd_photo_count + 1 開始

存檔防覆蓋機制

每次按下「📂 存到 SD 卡」時,程式不是直接用編號存檔,而是會先確認檔名不存在:

do {
    sd_photo_count++;
    snprintf(filepath, sizeof(filepath), "/photo_%03d.jpg", sd_photo_count);
} while (SD_MMC.exists(filepath));  // 如果檔名已存在就跳號

這個 do-while 迴圈確保即使你在電腦上手動把照片放回 SD 卡、或者從其他來源複製了同名檔案,程式都會自動跳過已存在的編號。例如:

SD 卡內已有的檔案 程式會存成
(空的) photo_001.jpg
photo_001 ~ photo_005 photo_006.jpg
photo_001, photo_003, photo_005 photo_002.jpg(找到第一個空號)
photo_001 ~ photo_010,但 photo_007 被刪了 photo_007.jpg

API 回傳格式

/save_sd API 回傳 JSON 格式,讓前端網頁能正確顯示結果:

狀況 回傳的 JSON
存檔成功 {"ok":true, "file":"/photo_006.jpg", "size":45678}
還沒拍照 {"ok":false, "msg":"no photo"}
沒有 SD 卡 {"ok":false, "msg":"no sd card"}
寫入失敗 {"ok":false, "msg":"write failed"}
寫入不完整 {"ok":false, "msg":"incomplete write"}

取出照片

拍照存好之後,關掉 ESP32-CAM 電源,把 Micro SD 卡拔出來插到電腦的讀卡機,就能看到根目錄下的 photo_001.jpgphoto_002.jpg 等檔案。


四、常見問題

問題 解決方法
上傳卡在 Connecting 上傳底板:按住 IO0 → 按 Reset → 放開 IO0
攝影機啟動失敗 排線鬆了,打開卡扣重新壓好
Wi-Fi 一直連不上 檢查 ssid 和 password 大小寫
畫面有水平條紋 供電不足,換 5V/2A 充電器
SD 卡掛載失敗 確認格式為 FAT32,建議 4~32GB
SD 卡偵測不到 卡片沒插好,或接觸不良,重新插入
存到 SD 卡顯示寫入失敗 SD 卡可能滿了,或卡片有寫入保護
照片編號沒有接續 正常現象,do-while 會跳過已存在的檔名
拍照後 crash 確認 static const char html[]cfg.stack_size = 8192
照片偏綠 程式已處理(丟掉前 3 幀),可增加到 5 幀
閃光燈不能用 1-bit MMC 模式不影響 GPIO4,閃光燈正常可用

五、自訂修改指南

要改什麼 改哪個參數 建議值
串流解析度 STREAM_FRAME_SIZE QVGA 或 VGA
串流品質 STREAM_QUALITY 10~15
拍照解析度 CAPTURE_FRAME_SIZE VGA ~ UXGA
拍照品質 CAPTURE_QUALITY 4~8
下載檔名 PHOTO_FILENAME 任意 .jpg 檔名
攝影機時鐘 XCLK_FREQ 10000000 或 20000000
HTTP stack HTTPD_STACK_SIZE 8192(通常不用動)

如果想把拍照解析度改回最大(1600×1200),只要改一行:

#define CAPTURE_FRAME_SIZE  FRAMESIZE_UXGA

其他都不用動,程式會自動用 UXGA 初始化攝影機。

總結:

經過這三篇教學,大家已經把ESP3E2-CAM基本的操作都學會了!接下來就能夠以這些為基礎做更進階的應用了,大家加油!

ESP32-CAM開發板 帶OV2640攝影模組 可選購上傳底板
ESP32-CAM開發板 帶OV2640攝影模組 本商品為相容版本,請注意哦! ※ 32位元CPU,可作應用處理器 ※ 主頻高達240MHz,運算能力高達 600 DMIPS ※ 內置 520 KB SRAM,外置4M PSRAM ※ 支持UART/SPI/I2C/PWM/ADC/DAC等接口 ※ 支持OV2640和OV7670攝像頭,內置閃光燈 ※ 支持圖片WiFi上傳 ※ 支持TF卡 ※ 支持多種休眠模式 ※ 內嵌Lwip和FreeRTOS ※ 支持 STA/AP/STA+AP 工作模式 ※