今天主打:不靠任何後端,就讓 ESP32-CAM 變聰明。
你會做出:移動偵測 → 連拍存證 →(選用)直接上 LINE Notify,外加 深度睡眠省電 timelapse。
全文可直接貼上部落格(純 Markdown + ASCII 圖表)。
[ESP32-CAM]
├─ 環境/畫面 → 判斷「有/沒有動作」
│ ├─ (A) JPEG 大小變化
│ └─ (B) 灰階差分(低解析)
├─ 事件成立 → 連拍3張 → 存 /DCIM/yyyy-mm-dd/
│ └─ 清理 >7天舊檔
├─ 夜間 → 先開LED補光60ms再拍
└─ (選用)把JPG上傳到 LINE Notify
/sdcard/DCIM/
├─ 2025-10-06/
│ ├─ 20-41-02.jpg
│ ├─ event_20-41-03_1.jpg
│ ├─ event_20-41-03_2.jpg
│ └─ event_20-41-03_3.jpg
└─ 2025-10-07/
└─ ...
觀念:場景改變(有人走過、燈光變化)→ 壓縮後的 JPEG 大小(bytes) 通常明顯改變。省 RAM、不需轉格式。
// --- 偵測主程式骨架:以「JPEG大小變化」判斷 ---
#include "esp_camera.h"
#include "FS.h"
#include "SD_MMC.h"
#include <WiFi.h>
#include <time.h>
#define LED_PIN 4 // 多數 ESP32-CAM 的補光LED
const int KEEP_DAYS = 7; // 保留天數
const int COOLDOWN = 15*1000; // ms
float THRESH = 0.12; // JPEG大小變化門檻(12%)
unsigned long lastEvent = 0;
size_t lastLen = 0;
String nowDate(), nowTime(); // Day3 介紹過的時間格式函式
void ensureDir(const String& dir);
void saveShot(const String& prefix=""); // 存JPG到 /DCIM/yyyy-mm-dd/
void cleanOld(); // 刪除舊資料夾(>KEEP_DAYS)
bool darkScene(size_t jpgLen){ // 粗略判斷「太暗」(可自行調)
return jpgLen < 18000; // QVGA 低於 ~18KB 視為偏暗
}
void setup(){
// 1) camera_config_t + esp_camera_init(...) 省略(沿用前幾天配置)
// 建議 framesize 設 VGA / SVGA,畫面較清楚
// 2) SD 卡初始化 & Wi-Fi & NTP(沿用 Day3)
SD_MMC.begin("/sdcard", true);
pinMode(LED_PIN, OUTPUT);
}
void loop(){
camera_fb_t *fb = esp_camera_fb_get();
if(!fb) return;
size_t L = fb->len; // JPEG 大小
bool moving = (lastLen>0) && (abs((int)L-(int)lastLen) > lastLen*THRESH);
lastLen = L;
unsigned long now = millis();
bool cooldown = (now - lastEvent < COOLDOWN);
if(moving && !cooldown){
// 夜間先補光一下
if(darkScene(L)) { digitalWrite(LED_PIN, HIGH); delay(60); }
esp_camera_fb_return(fb); // 釋放本幀,改用 saveShot 正式存圖
saveShot("event"); delay(150);
saveShot("event"); delay(150);
saveShot("event");
digitalWrite(LED_PIN, LOW);
lastEvent = millis();
}else{
esp_camera_fb_return(fb);
}
static unsigned long tClean=0;
if(millis()-tClean>3600000UL){ cleanOld(); tClean=millis(); } // 每小時清理一次
delay(100);
}
調參建議
THRESH
:0.08~0.2 之間實測調整;越小越敏感。COOLDOWN
:避免「同一個人路過」爆訊,10~30 秒看場景調整。darkScene()
:可改成「平均亮度」判斷(見下一段 B 法)。作法:先用「低解析度、灰階」捕捉一幀(QQVGA 160×120),和上一幀做差分;若差值比例超過門檻 → 切回高畫質 JPEG 連拍存證。
注意:動態切換像素格式最穩的是 重新初始化 camera(會有 100~300ms 的切換時間)。以下提供可用骨架(示意縮寫):
#include "esp_camera.h"
// A) 先以灰階+低解析初始化(偵測模式)
camera_config_t cfgDetect = {
.pin_pwdn = 32, .pin_reset = -1, .pin_xclk = 0,
.pin_sccb_sda = 26, .pin_sccb_scl = 27,
.pin_d7 = 35, .pin_d6 = 34, .pin_d5 = 39, .pin_d4 = 36,
.pin_d3 = 21, .pin_d2 = 19, .pin_d1 = 18, .pin_d0 = 5,
.pin_vsync = 25, .pin_href = 23, .pin_pclk = 22,
.xclk_freq_hz = 20000000,
.ledc_timer = LEDC_TIMER_0, .ledc_channel = LEDC_CHANNEL_0,
.pixel_format = PIXFORMAT_GRAYSCALE,
.frame_size = FRAMESIZE_QQVGA, // 160×120
.jpeg_quality = 12, .fb_count = 1
};
// B) 另備高畫質 JPEG 設定(連拍模式)
camera_config_t cfgShoot = cfgDetect;
void setupCfgShoot(){
cfgShoot.pixel_format = PIXFORMAT_JPEG;
cfgShoot.frame_size = FRAMESIZE_SVGA; // 800×600 可改 VGA/XGA
cfgShoot.jpeg_quality = 10; // 數值小=畫質高=檔案大
}
bool initCam(const camera_config_t& c){ return (esp_camera_init(&c)==ESP_OK); }
void deinitCam(){ esp_camera_deinit(); }
bool motionDetected(uint8_t* prev, uint8_t* curr, size_t W, size_t H){
// 灰階差分:統計 |curr-prev| > T 的像素比例
const int T = 12; // 差異門檻
size_t cnt=0, tot=W*H;
for(size_t i=0;i<tot;i++){
int d = (int)curr[i]-(int)prev[i];
if(d<0) d=-d;
if(d>T) cnt++;
}
return ( (float)cnt / (float)tot ) > 0.06; // 超過 6% 視為有動作
}
void loop(){
static std::unique_ptr<uint8_t[]> prev;
static size_t W=160, H=120;
// --- 偵測模式 ---
camera_fb_t *fb = esp_camera_fb_get(); // GRAYSCALE QQVGA
if(!fb) return;
if(!prev) { prev.reset(new uint8_t[fb->len]); memcpy(prev.get(), fb->buf, fb->len); }
bool moving = motionDetected(prev.get(), fb->buf, W, H);
memcpy(prev.get(), fb->buf, fb->len);
esp_camera_fb_return(fb);
if(moving){
// 切換到高畫質 JPEG,連拍存證
deinitCam();
setupCfgShoot();
initCam(cfgShoot);
saveShot("event"); delay(150);
saveShot("event"); delay(150);
saveShot("event");
deinitCam();
initCam(cfgDetect); // 回偵測模式
}
delay(80);
}
何時選 B 法?
ROI(Region of Interest):只關注畫面某一塊
把灰階差分改成只對「門口區」或「走廊區」計算(例如 x∈[30,120], y∈[20,90])。
冷卻時間:COOLDOWN
(10~30s)。
夜間策略:darkScene()
為真 → 先 LED_PIN=HIGH
60ms 再拍。
不經外部伺服器,直接把剛拍的
event_*.jpg
上傳到 LINE Notify。
#include <WiFiClientSecure.h>
#include <HTTPClient.h>
const char* LINE_TOKEN = "你的_LINE_TOKEN"; // 申請:notify-bot.line.me
bool postLineImage(const String& path){
File f = SD_MMC.open(path, FILE_READ);
if(!f) return false;
WiFiClientSecure client; client.setInsecure(); // demo:省略驗證
HTTPClient http;
if(!http.begin(client, "https://notify-api.line.me/api/notify")) return false;
http.addHeader("Authorization", String("Bearer ")+LINE_TOKEN);
String boundary = "----esp32camFormBoundary";
http.addHeader("Content-Type", "multipart/form-data; boundary=" + boundary);
// 組 multipart
String head = "--"+boundary+"\r\nContent-Disposition: form-data; name=\"message\"\r\n\r\n有事件發生!\r\n";
head += "--"+boundary+"\r\nContent-Disposition: form-data; name=\"imageFile\"; filename=\"event.jpg\"\r\n";
head += "Content-Type: image/jpeg\r\n\r\n";
String tail = "\r\n--"+boundary+"--\r\n";
int size = head.length() + f.size() + tail.length();
http.addHeader("Content-Length", String(size));
WiFiClient *s = http.getStreamPtr();
http.collectHeaders(nullptr,0);
s->print(head);
uint8_t buf[1024];
while(int n=f.read(buf, sizeof(buf)); n>0) s->write(buf, n);
s->print(tail);
int code = http.POST(nullptr, 0); // 已直接寫入 stream
http.end(); f.close();
return (code==200);
}
注意:LINE 附圖大小限制 ≈ 10MB;太大就先在 ESP32 降畫質再拍。
若你要做「戶外長期 timelapse」,Deep Sleep 是續航關鍵。
#include "esp_sleep.h"
void deepSleepMinutes(int m){
esp_sleep_enable_timer_wakeup((uint64_t)m * 60ULL * 1000000ULL);
esp_deep_sleep_start();
}
void setup(){
// camera + SD + Wi-Fi + NTP...
saveShot("tl"); // 醒來先拍
deepSleepMinutes(5); // 再睡5分鐘
}
void loop(){}
其他喚醒:PIR 感測器可用 esp_sleep_enable_ext1_wakeup()
(選 RTC GPIO 腳位)。
近24h 事件數: 37 次
連拍平均檔數: 2.9 / 次
上傳成功率: 95%
平均冷卻間隔: 17s
黑夜補光比例: 62%
THRESH
、加 COOLDOWN
、縮小 ROI。60~90ms
再拍;或降畫質(JPEG 質量數值大一點)。f.flush()
,避免斷電。esp_camera_init()
,再拍。