ESP32-CAM 網路影像串流,初學者完整教學(拍照篇)
有了ESP32-CAM開發板,就能做出一台網路攝影機!但我們不只要即時串流,還要一鍵高畫質拍照、手機直接下載!完整程式碼不到 200 行,初學者也能 10 分鐘上手。
上一篇教學大家已經學會如何用ESP32-CAM這款開發板,製作出可以監看即時影像的串流的功能,接下來我們再加一個功能,就是拍下特定畫面,並且可以下載到電腦或手機中。
如果你尚未看過上一篇,傑森強烈建議先看一下哦!


接下來我們要寫的這支程式,除了原本就有的影像串流以外,再加上了3個功能:
- 即時串流 — 用低畫質(320×240)流暢播放攝影機畫面
- 高畫質拍照 — 按下拍照按鈕,自動切換到最大解析度拍一張清晰的照片
- 下載存檔 — 拍完後直接在瀏覽器預覽照片,按按鈕就能下載到手機或電腦
不需要安裝任何 App,也不需要 SD 卡。
一、程式碼
有關Arduino IDE的準備,還有基本的串流功能,我們這篇就不重複說明了,請需要了解的人前往前一篇看看哦,這邊我們直就接進入程式的說明囉!

將以下程式碼貼到 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的其它功能,請期待!




