ESP32-CAM 網路影像串流,初學者完整教學(拍照篇)

有了ESP32-CAM開發板,就能做出一台網路攝影機!但我們不只要即時串流,還要一鍵高畫質拍照、手機直接下載!完整程式碼不到 200 行,初學者也能 10 分鐘上手。

ESP32-CAM 網路影像串流,初學者完整教學(拍照篇)

上一篇教學大家已經學會如何用ESP32-CAM這款開發板,製作出可以監看即時影像的串流的功能,接下來我們再加一個功能,就是拍下特定畫面,並且可以下載到電腦或手機中。

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

ESP32-CAM 網路影像串流,初學者完整教學
把 ESP32-CAM 開發板連上你家的 Wi-Fi,然後用手機或電腦的瀏覽器,就能即時看到攝影機的畫面。不需要安裝任何 App,只要在同一個 Wi-Fi 底下,打開網頁就能看。
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 工作模式 ※

接下來我們要寫的這支程式,除了原本就有的影像串流以外,再加上了3個功能:

  1. 即時串流 — 用低畫質(320×240)流暢播放攝影機畫面
  2. 高畫質拍照 — 按下拍照按鈕,自動切換到最大解析度拍一張清晰的照片
  3. 下載存檔 — 拍完後直接在瀏覽器預覽照片,按按鈕就能下載到手機或電腦

不需要安裝任何 App,也不需要 SD 卡。


一、程式碼

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

ESP32-CAM 網路影像串流,初學者完整教學
把 ESP32-CAM 開發板連上你家的 Wi-Fi,然後用手機或電腦的瀏覽器,就能即時看到攝影機的畫面。不需要安裝任何 App,只要在同一個 Wi-Fi 底下,打開網頁就能看。

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

#include "esp_camera.h"    // ESP32 攝影機函式庫
#include <WiFi.h>           // Wi-Fi 連線函式庫
#include "esp_http_server.h" // HTTP 伺服器函式庫

// ===== 請改成你自己的 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  // 拍照解析度 640 x 480
#define CAPTURE_QUALITY     4               // 拍照 JPEG 品質(建議 4~8)

// --- 其他設定 ---
#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(畫質好)
 */

// ============================================================
//  以下是攝影機腳位定義,AI-Thinker 固定的,不用改
// ============================================================
#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 儲存,因為高解析度照片可達 200KB 以上,
// ESP32 內部 SRAM 只有 520KB(還要跑程式),放不太下。
// ESP32-CAM 板子上的 4MB PSRAM 就是用來做這件事的。
//
uint8_t* captured_buf = NULL;   // 指向照片資料的指標
size_t captured_len = 0;        // 照片資料大小(bytes)

// ============================================================
//  工具函式:動態切換攝影機的解析度和品質
// ============================================================
//
// OV2640 攝影機支援在運作中動態切換解析度,
// 但有一個前提:初始化時必須用「最大的解析度」,
// 之後才能往下切到任意較小的解析度。
//
// 這就是為什麼 setup() 裡用 CAPTURE_FRAME_SIZE初始化,
// 然後立刻切回 STREAM_FRAME_SIZE(QVGA)來串流。
//
void setCameraParams(framesize_t frameSize, int quality) {
    sensor_t *s = esp_camera_sensor_get();  // 取得攝影機感測器控制物件
    s->set_framesize(s, frameSize);         // 設定解析度
    s->set_quality(s, quality);             // 設定 JPEG 壓縮品質
}

// ============================================================
//  串流處理函式(簡要說明,本篇重點在拍照)
// ============================================================
//
// MJPEG 串流原理:不斷拍 JPEG → 用 HTTP chunked 傳給瀏覽器。
// 瀏覽器收到 multipart 格式的連續圖片就會自動播放成「影片」。
//
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();
        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);

        if (res != ESP_OK) break;
    }
    return res;
}

// ============================================================
//  【核心功能】拍照 API — /capture
// ============================================================
//
// 當使用者在網頁上按「拍照」按鈕,瀏覽器會呼叫這個 API。
// 完整流程如下:
//
//   ┌──────────────┐
//   │ 使用者按拍照  │
//   └──────┬───────┘
//          ↓
//   ┌──────────────────────┐
//   │ 1. 釋放舊照片的記憶體  │  ← 避免記憶體洩漏
//   └──────┬───────────────┘
//          ↓
//   ┌──────────────────────┐
//   │ 2. 切換到高解析度模式  │  
//   │    等待 300ms 穩定     │
//   └──────┬───────────────┘
//          ↓
//   ┌──────────────────────┐
//   │ 3. 丟掉前 3 幀        │  ← 讓自動曝光穩定
//   └──────┬───────────────┘     (否則照片會偏綠或過亮)
//          ↓
//   ┌──────────────────────┐
//   │ 4. 正式拍一張照片      │  ← esp_camera_fb_get()
//   └──────┬───────────────┘
//          ↓
//   ┌──────────────────────┐
//   │ 5. 複製到 PSRAM 暫存   │  ← 因為 fb 歸還後資料就沒了
//   └──────┬───────────────┘
//          ↓
//   ┌──────────────────────┐
//   │ 6. 切回低解析度串流    │  ← QVGA 320x240
//   └──────┬───────────────┘
//          ↓
//   ┌──────────────────────┐
//   │ 7. 回傳 "ok" 給瀏覽器 │  ← 瀏覽器收到後跳轉到預覽頁
//   └──────────────────────┘
//
static esp_err_t capture_handler(httpd_req_t *req) {

    // --- 步驟 1:釋放舊照片 ---
    // 每次拍新照片前,先把上一張的記憶體釋放掉。
    // 如果不釋放,拍很多張之後記憶體就會被吃光(memory leak)。
    if (captured_buf) {
        free(captured_buf);
        captured_buf = NULL;
        captured_len = 0;
    }

    // --- 步驟 2:切換到拍照模式(高解析度 + 高品質)---
    // 串流時用 QVGA(320x240)是為了流暢,
    // 但拍照時我們要最好的畫質,所以切到 UXGA(1600x1200)。
    setCameraParams(CAPTURE_FRAME_SIZE, CAPTURE_QUALITY);
    delay(300);  // 攝影機切換解析度後需要一點時間穩定

    // --- 步驟 3:丟掉前幾幀,讓自動曝光穩定 ---
    // OV2640 有自動曝光(AEC)功能,切換解析度後前幾幀的
    // 亮度和色彩還沒調整好,所以先拍幾張丟掉不用。
    // 這也能避免第一張照片偏綠的常見問題。
    for (int i = 0; i < 3; i++) {
        camera_fb_t *fb = esp_camera_fb_get();
        if (fb) esp_camera_fb_return(fb);  // 拍了就丟
        delay(100);
    }

    // --- 步驟 4:正式拍攝 ---
    // esp_camera_fb_get() 會從攝影機取得一幀畫面,
    // 回傳的 fb(frame buffer)包含:
    //   fb->buf  → JPEG 圖片的原始資料(byte 陣列)
    //   fb->len  → 資料長度(多少 bytes)
    //   fb->width, fb->height → 圖片的寬和高
    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;
    }

    // --- 步驟 5:複製照片到我們自己的暫存區 ---
    // 為什麼要複製?因為 fb 是攝影機的內部 buffer,
    // 呼叫 esp_camera_fb_return() 歸還後資料就會被覆蓋。
    // 但使用者可能過一會兒才按下載,所以要先複製一份保留。
    //
    // ps_malloc() = PSRAM malloc,優先使用外部 PSRAM 分配記憶體。
    // UXGA 的 JPEG 大約 100~300KB,放在 PSRAM(4MB)綽綽有餘,
    // 如果放在內部 SRAM(520KB)就太擠了。
    captured_buf = (uint8_t*)ps_malloc(fb->len);
    if (!captured_buf) {
        captured_buf = (uint8_t*)malloc(fb->len);  // PSRAM 失敗就退而求其次
    }

    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("記憶體不足,無法儲存照片");
    }

    // --- 步驟 6:歸還 frame buffer,切回串流模式 ---
    esp_camera_fb_return(fb);
    setCameraParams(STREAM_FRAME_SIZE, STREAM_QUALITY);

    // --- 步驟 7:回傳結果給瀏覽器 ---
    httpd_resp_set_type(req, "text/plain");
    httpd_resp_send(req, captured_buf ? "ok" : "fail", captured_buf ? 2 : 4);
    return ESP_OK;
}

// ============================================================
//  【核心功能】照片 API — /photo
// ============================================================
//
// 這個 API 負責把暫存的照片傳給瀏覽器。有兩種用法:
//
//   /photo            → 瀏覽器直接顯示圖片(用在預覽頁的 <img src="/photo">)
//   /photo?download=1 → 瀏覽器跳出「另存新檔」視窗
//
// 差別只在 HTTP header:
//   沒有 Content-Disposition → 瀏覽器顯示圖片
//   有 Content-Disposition: attachment → 瀏覽器下載檔案
//
static esp_err_t photo_handler(httpd_req_t *req) {
    // 如果還沒拍照就來要照片,回傳 404
    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;
        }
    }

    // 設定回傳類型為 JPEG 圖片
    httpd_resp_set_type(req, "image/jpeg");

    if (is_download) {
        // attachment 會讓瀏覽器跳出下載視窗,filename 是建議的檔名
        httpd_resp_set_hdr(req, "Content-Disposition",
            "attachment; filename=" PHOTO_FILENAME);
    }

    // 把照片資料一次送出
    httpd_resp_send(req, (const char *)captured_buf, captured_len);
    return ESP_OK;
}

// ============================================================
//  首頁 HTML — 串流畫面 + 拍照按鈕
// ============================================================
//
// 注意:用 static const 宣告 HTML 字串,讓它放在全域記憶體,
// 而不是每次呼叫 handler 時都塞進 stack。
// 如果不加 static,HTML 太長會把 httpd 的 stack 擠爆,
// 導致 "Stack canary watchpoint triggered" 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(和網頁的 port 80 分開,互不干擾)
window.onload=function(){
    document.getElementById('stream').src='http://'+location.hostname+':81/stream';
}

// --- 拍照按鈕的動作 ---
function takePhoto(){
    var btn=document.getElementById('captureBtn');
    var st=document.getElementById('status');

    // 按鈕變灰色,防止連按
    btn.disabled=true;
    btn.textContent='⏳ 拍照中...';
    st.textContent='正在切換高畫質拍照,請稍候...';

    // 呼叫 /capture API,ESP32 會拍一張高畫質照片
    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 — 顯示照片 + 存檔/返回按鈕
// ============================================================
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 28px;
    font-size:15px;border:none;border-radius:6px;
    cursor:pointer;color:#fff;text-decoration:none;
}
.btn-save{background:#27ae60}
.btn-back{background:#555}
.btn:active{transform:scale(0.95)}
</style>
</head>
<body>
<h1>📷 照片預覽</h1>
<p class="sub">傑森創工</p>
<!-- 直接用 /photo 當 img 的 src,瀏覽器會顯示暫存的照片 -->
<img src="/photo">
<div class="btn-row">
    <!-- download=1 讓瀏覽器跳出下載視窗 -->
    <a class="btn btn-save" href="/photo?download=1">💾 儲存照片</a>
    <!-- 返回首頁就會重新載入串流 -->
    <a class="btn btn-back" href="/">🔙 返回串流</a>
</div>
</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 的規則:初始化用多大,之後最大就只能切到多大。
    // 所以這裡用 VGA,之後拍照才能切到這個解析度。
    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;

    if (esp_camera_init(&config) != ESP_OK) {
        Serial.println("攝影機啟動失敗!請檢查排線");
        return;
    }

    // 初始化完成後,立刻切回低解析度給串流用
    setCameraParams(STREAM_FRAME_SIZE, STREAM_QUALITY);
    Serial.println("攝影機啟動成功");

    // --- 連接 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 網頁伺服器 ---
    // 這個伺服器處理 4 個網址:
    //   /         → 首頁(串流 + 拍照按鈕)
    //   /capture  → 拍照 API
    //   /preview  → 照片預覽頁
    //   /photo    → 回傳照片 JPEG 資料
    httpd_config_t cfg = HTTPD_DEFAULT_CONFIG();
    cfg.server_port = 80;
    cfg.stack_size = HTTPD_STACK_SIZE;  // 加大 stack,避免 HTML 太大導致 crash

    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 };

    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);
    }

    // --- 啟動 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.println("=========================================");
}

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

程式架構說明

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

Port 80(網頁伺服器) 負責處理四個網址路徑:/ 是首頁,顯示串流畫面和拍照按鈕;/capture 是拍照 API,收到請求後會切換到高解析度拍一張照片;/preview 是預覽頁面,顯示剛拍的照片和存檔按鈕;/photo 是照片 API,負責把暫存的 JPEG 資料傳給瀏覽器顯示或下載。

Port 81(串流伺服器) 只處理一個路徑 /stream,負責不斷從攝影機取得 JPEG 幀並用 MJPEG 格式持續傳送給瀏覽器。串流和網頁分開兩個 port,是為了讓拍照操作不會中斷串流的連線。

串流與拍照的解析度切換

程式的核心設計是「串流用低畫質,拍照用高畫質」。平時串流維持在 QVGA(320×240)確保畫面流暢,當使用者按下拍照按鈕時,程式會透過 setCameraParams() 即時把攝影機切換到 VGA(640x480)拍一張高解析度照片,拍完再切回 QVGA 繼續串流。這裡有一個重要細節:OV2640 攝影機在初始化時必須用最大解析度,之後才能動態切換到任意較小的解析度,所以 setup() 裡是用 UXGA 初始化,啟動後才立刻切回 QVGA。

拍照流程的三個關鍵處理

拍照並不是單純呼叫一次 esp_camera_fb_get() 就好。程式做了三件重要的事:第一,切換解析度後先等 300 毫秒讓攝影機穩定;第二,連續拍 3 幀丟掉不用,因為自動曝光還沒調整好,前幾幀容易偏綠或過亮;第三,正式拍攝後用 ps_malloc() 把照片複製到 PSRAM 暫存,因為攝影機的 frame buffer 歸還後資料就會被覆蓋,而使用者可能過一會兒才按下載。

照片下載的實現方式

照片 API(/photo)根據網址參數決定行為:不帶參數時回傳 JPEG 讓瀏覽器直接顯示(用在預覽頁的 <img> 標籤),帶上 ?download=1 時則在 HTTP header 加入 Content-Disposition: attachment,瀏覽器收到這個 header 就會跳出「另存新檔」視窗而不是直接顯示圖片。

避免 crash 的兩個重點

程式中有兩個防止系統崩潰的設計。第一是所有 HTML 字串都用 static const 宣告,讓它們放在全域記憶體而非 stack,因為 httpd 預設只有 4096 bytes 的 stack 空間,HTML 太大會直接爆掉。第二是把 cfg.stack_size 從預設的 4096 加大到 8192,給 HTTP 處理函式更多的堆疊空間,雙重保險避免 Stack canary watchpoint triggered 的 crash。


二、使用方式

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

攝影機啟動成功
正在連接 Wi-Fi..............
Wi-Fi 已連線!
=========================================
  開啟網頁:http://192.168.1.143
=========================================

用瀏覽器開啟那個網址,操作流程如下:


三、拍照功能技術解析

為什麼串流和拍照要用不同解析度?

ESP32-CAM 的運算能力有限。如果用 1600×1200 來串流,每幀的 JPEG 檔案太大(200KB 以上),透過 Wi-Fi 傳輸會嚴重掉幀,畫面非常卡。所以串流用最小的 320×240(每幀只有幾 KB),拍照時才臨時切到最大。

為什麼要丟掉前幾幀?

OV2640 的自動曝光(AEC)需要分析畫面亮度來調整快門和增益。切換解析度後,前幾幀的參數還是舊的,拍出來的照片會:

  • 偏綠色(白平衡還沒調好)
  • 過亮或過暗(曝光值還沒穩定)

所以拍 3 幀丟掉,第 4 幀才是正式的照片。

為什麼用 PSRAM?

ESP32-CAM(AI-Thinker)板上有 4MB 的 PSRAM(外部記憶體)。高解析度照片動輒 100~300KB,如果放在內部 SRAM(只有 520KB,還要跑程式和 Wi-Fi),記憶體會不夠。ps_malloc() 就是專門從 PSRAM 分配記憶體的函式。

為什麼 HTML 要加 static?

ESP32 的 HTTP 伺服器(httpd)預設只有 4096 bytes 的 stack 空間。如果 HTML 字串宣告為普通的區域變數(const char html[]),每次有人連進來,這幾百 bytes 的字串就會被塞進 stack,很容易就超過 4096 的上限,導致 Stack canary watchpoint triggered crash。

加上 static 後,字串只會在程式啟動時分配一次到全域記憶體,不佔 stack。我們同時也把 cfg.stack_size 從 4096 加大到 8192,雙重保險。


四、常見問題

問題 解決方法
上傳卡在 Connecting 上傳底板:按住 IO0 → 按 Reset → 放開 IO0。FTDI:確認 GPIO0 接 GND
Serial Monitor 亂碼 鮑率改成 115200
攝影機啟動失敗 排線鬆了,打開卡扣重新壓好
Wi-Fi 一直連不上 檢查 ssid 和 password 大小寫
畫面有水平條紋 供電不足,換 5V/2A 充電器
拍照後 crash 重啟 確認 static const char html[]cfg.stack_size = 8192
照片偏綠 程式已處理(丟掉前 3 幀),如果還是偏綠可增加到 5 幀
記憶體不足 確認板子有 PSRAM,Arduino IDE 要勾選啟用 PSRAM
下載的照片打不開 確認 JPEG 品質設定在 4~12 之間

五、自訂修改指南

所有可調參數都集中在程式最上方,方便你日後修改:

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

現在大家已經會使用串流影片,然後還會拍單張照片了,太棒了!接著,傑森還會再介紹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 工作模式 ※