iT邦幫忙

2025 iThome 鐵人賽

DAY 25
0
自我挑戰組

IT工具與自我學IT的過程分享系列 第 25

Day 5|只用 ESP32-CAM,也能很聰明:偵測、連拍、上雲、低功耗

  • 分享至 

  • xImage
  •  

Day 5|只用 ESP32-CAM,也能很聰明:偵測、連拍、上雲、低功耗

今天主打:不靠任何後端,就讓 ESP32-CAM 變聰明。
你會做出:移動偵測 → 連拍存證 →(選用)直接上 LINE Notify,外加 深度睡眠省電 timelapse
全文可直接貼上部落格(純 Markdown + ASCII 圖表)。


0) 你將完成

  • 移動偵測(兩種難度)
    A. 超省資源版:JPEG 檔大小變化 判斷
    B. 進階版:低解析度 灰階差分 判斷(抓到就切回高畫質拍照)
  • 事件處理:冷卻時間、ROI(關注區域)、夜間加補光
  • 存證策略:連拍 3 張 + SD 卡按日分資料夾 + 自動清理舊檔
  • (選用)直接上雲:ESP32-CAM 自己把照片 POST 到 LINE Notify
  • 省電模式Deep Sleep timelapse(醒來拍一張→立刻睡)

A. 架構腦圖

[ESP32-CAM]
   ├─ 環境/畫面 → 判斷「有/沒有動作」
   │              ├─ (A) JPEG 大小變化
   │              └─ (B) 灰階差分(低解析)
   ├─ 事件成立 → 連拍3張 → 存 /DCIM/yyyy-mm-dd/
   │                        └─ 清理 >7天舊檔
   ├─ 夜間 → 先開LED補光60ms再拍
   └─ (選用)把JPG上傳到 LINE Notify

B. 事件檔案結構(延續 Day 3)

/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/
       └─ ...

C. 偵測法 A(最實用)|JPEG 檔大小變化(省資源、好上手)

觀念:場景改變(有人走過、燈光變化)→ 壓縮後的 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 法)。

D. 偵測法 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 法?

  • 場景容易因光線而「JPEG大小」擾動時,用灰階差分更穩。
  • 但切換模式會有短暫黑屏;如不想切換,就用 A 法

E. 事件防刷:ROI(關鍵區域)、冷卻、夜間策略

ROI(Region of Interest):只關注畫面某一塊
把灰階差分改成只對「門口區」或「走廊區」計算(例如 x∈[30,120], y∈[20,90])。
冷卻時間COOLDOWN(10~30s)。
夜間策略darkScene() 為真 → 先 LED_PIN=HIGH 60ms 再拍。


F. 上雲(選用):直接發 LINE Notify(ESP32-CAM 自己上)

不經外部伺服器,直接把剛拍的 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 降畫質再拍。


G. 低功耗 timelapse:Deep Sleep(醒來→拍→睡)

若你要做「戶外長期 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 腳位)。


H. 觀測面板(ASCII):事件統計 & 成功率

近24h 事件數: 37 次
連拍平均檔數: 2.9 / 次
上傳成功率:   95%
平均冷卻間隔: 17s
黑夜補光比例: 62%

I. 常見問題(只用 ESP32-CAM 版)

  • 誤報太多:拉高 THRESH、加 COOLDOWN、縮小 ROI。
  • 黑夜全黑:先補光 60~90ms 再拍;或降畫質(JPEG 質量數值大一點)。
  • SD 卡壞檔:A1/A2 卡、FAT32 重格;寫入後 f.flush(),避免斷電。
  • LINE 上傳失敗:檔案 <10MB、Wi-Fi 穩、重試退避 2/4/8 秒。
  • 深度睡眠醒來黑屏:確保重新 esp_camera_init(),再拍。

J. 今日任務清單 ✅

  • [ ] 偵測法 A 跑通(JPEG 大小變化)
  • [ ] 連拍存證 + 按日分資料夾
  • [ ] 設定冷卻時間與夜間補光
  • [ ] (選用)成功把事件圖丟到 LINE Notify
  • [ ] Deep Sleep timelapse 成功(醒來就拍 → 再睡)

K. 明天預告(Day 6)

  • 以「專題級」思維整合:門口管家 / 寵物自拍 / 客流計數——流程圖、KPI、測試腳本成果報表一次給你。

上一篇
Day 4|ESP32-CAM網路應用:HTTP / MQTT / LINE Notify(把相機變會講話的同事)
下一篇
Day 6|ESP32-CAM專題級應用藍圖:門口管家 / 寵物自拍 / 客流計數
系列文
IT工具與自我學IT的過程分享28
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言