iT邦幫忙

2025 iThome 鐵人賽

DAY 23
0
自我挑戰組

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

Day 3|ESP32-CAM拍照 / 錄影 / SD 卡 × 低延遲調校 × API(把相機變「會工作的相機」)

  • 分享至 

  • xImage
  •  

Day 3|ESP32-CAM拍照 / 錄影 / SD 卡 × 低延遲調校 × API(把相機變「會工作的相機」)

目標:讓 ESP32-CAM 不只是「會亮畫面」,而是會自動拍、會排程、會存證、不卡關
亮點:新增 timelapse 排程、事件快照、檔案保留(自動清空舊檔),再給你低延遲調校清單實測小工具


0) TL;DR(今天完成)

  • SD 卡時間戳存圖按日分資料夾
  • Timelapse 排程(每 N 分鐘拍一次)+事件快照(遇到狀況多拍幾張)
  • 自動清理舊檔(保留近 7 天)
  • 不卡祕笈:解析度 / 幀率 / 網路 / 電源 四部曲
  • 實測工具:Python 量 FPS、掉幀、延遲趨勢

A. SD 卡存圖:時間戳+資料夾分天

檔案結構

/sdcard/DCIM/
  ├─ 2025-10-06/
  │    ├─ 00-15-00.jpg
  │    ├─ 00-30-00.jpg
  │    └─ event_00-42-12.jpg
  └─ 2025-10-07/
       └─ ...

Arduino(ESP32)範例:時間戳+當日資料夾

#include "esp_camera.h"
#include "FS.h"
#include "SD_MMC.h"
#include <WiFi.h>
#include <time.h>

const char* ssid="YOUR_SSID";
const char* pass="YOUR_PASS";
const char* ntp = "pool.ntp.org";
const long  gmtOffset = 8 * 3600;   // 依你時區調整
const int   daylightOffset = 0;

void ensureDir(const String& dir){
  if(!SD_MMC.exists(dir)) SD_MMC.mkdir(dir);
}

String nowDate(){ // yyyy-mm-dd
  time_t t; time(&t); struct tm *tm = localtime(&t);
  char buf[16]; strftime(buf, sizeof(buf), "%Y-%m-%d", tm);
  return String(buf);
}
String nowTime(){ // HH-MM-SS
  time_t t; time(&t); struct tm *tm = localtime(&t);
  char buf[16]; strftime(buf, sizeof(buf), "%H-%M-%S", tm);
  return String(buf);
}

void saveShot(const String& prefix=""){
  camera_fb_t *fb = esp_camera_fb_get();
  if(!fb) return;
  String dir = "/DCIM/" + nowDate();
  ensureDir(dir);
  String name = prefix.length() ? (prefix + "_" + nowTime()) : nowTime();
  String path = dir + "/" + name + ".jpg";
  File f = SD_MMC.open(path, FILE_WRITE);
  if(f){ f.write(fb->buf, fb->len); f.close(); }
  esp_camera_fb_return(fb);
}

void setup(){
  // 1) init camera_config_t + esp_camera_init(...)  略
  // 2) SD: 1-bit 模式較穩定
  SD_MMC.begin("/sdcard", true);
  // 3) Wi-Fi + NTP 校時
  WiFi.begin(ssid, pass); while(WiFi.status()!=WL_CONNECTED) delay(200);
  configTime(gmtOffset, daylightOffset, ntp);
}

void loop(){
  // demo:每 5 分鐘拍一張
  static unsigned long t0 = 0;
  if(millis() - t0 > 300000UL){
    saveShot(); // 00-15-00.jpg 這種檔名
    t0 = millis();
  }
  delay(100);
}

B. Timelapse + 事件快照 + 舊檔清理(保留 7 天,硬碟不爆)

情境

  • Timelapse:固定頻率拍照,如「每 5 分鐘一張」。
  • 事件快照:偵測到門鈴/紅外線/按鈕 → 連拍 3 張
  • 保留策略:自動刪除「7 天前」的資料夾。

事件快照 + 清理舊檔(擴充上段程式)

#define PIR_PIN 14 // 或門鈴/按鈕 GPIO
const int KEEP_DAYS = 7;

bool olderThanNDays(const String& yyyy_mm_dd, int days){
  struct tm tm_now, tm_dir;
  time_t tnow; time(&tnow); localtime_r(&tnow, &tm_now);
  // 解析 yyyy-mm-dd
  tm_dir = tm_now; // copy timezone
  int y,m,d; sscanf(yyyy_mm_dd.c_str(), "%d-%d-%d", &y,&m,&d);
  tm_dir.tm_year = y-1900; tm_dir.tm_mon = m-1; tm_dir.tm_mday = d;
  tm_dir.tm_hour = 0; tm_dir.tm_min = 0; tm_dir.tm_sec = 0;
  time_t tdir = mktime(&tm_dir);
  return difftime(tnow, tdir) > days*86400L;
}

void cleanOld(){
  File root = SD_MMC.open("/DCIM");
  File f; while((f = root.openNextFile())){
    if(!f.isDirectory()) continue;
    String name = String(f.name()); // /DCIM/2025-10-06
    String date = name.substring(6); // 2025-10-06
    if(olderThanNDays(date, KEEP_DAYS)){
      // 刪整個資料夾(遞迴簡化:先刪裡面,再刪它)
      File sub = SD_MMC.open(name);
      File ff; while((ff = sub.openNextFile())){ SD_MMC.remove(ff.path()); }
      SD_MMC.rmdir(name);
    }
  }
}

void loop(){
  // timelapse
  static unsigned long t0=0, t1=0;
  if(millis()-t0>300000UL){ saveShot(); t0=millis(); }     // 每 5 分鐘
  if(digitalRead(PIR_PIN)==HIGH){                          // 事件快照
    saveShot("event"); delay(200);
    saveShot("event"); delay(200);
    saveShot("event"); delay(500);
  }
  if(millis()-t1>3600000UL){ cleanOld(); t1=millis(); }    // 每小時清理一次
  delay(50);
}

C. API:小而美的 HTTP 服務(/shot 與 /burst)

最小可用 API(整合到你的 setup()loop()

#include <WebServer.h>
WebServer srv(80);

void handleShot(){
  camera_fb_t * fb = esp_camera_fb_get();
  if(!fb){ srv.send(500,"text/plain","fail"); return; }
  srv.sendHeader("Content-Type","image/jpeg");
  srv.send_P(200,"image/jpeg",(const char*)fb->buf, fb->len);
  esp_camera_fb_return(fb);
}
void handleBurst(){ // 連拍 3 張到 SD
  saveShot("burst"); delay(120);
  saveShot("burst"); delay(120);
  saveShot("burst");
  srv.send(200,"text/plain","ok");
}

void setup(){
  // ... camera + WiFi + SD + NTP ...
  srv.on("/shot", handleShot);
  srv.on("/burst", handleBurst);
  srv.begin();
}
void loop(){ srv.handleClient(); /* + 你的 timelapse/清理邏輯 */ }

安全(超簡化):把 ESP32-CAM 只放在內網;必要時加一個 query token:/shot?tk=SECRET


D. 不卡祕笈(低延遲調校清單)

1) 解析度 / 畫質

  • QVGA/VGA 調順,再升級到 SVGA/XGA。
  • 關掉多餘後處理(臉辨、雜訊濾波),流暢度 ↑。

2) 網路

  • 近距離接入點(AP),20MHz 頻寬更穩。
  • 場景複雜度下降(亮度足夠、背景單純),JPEG 壓縮更有效 → 位元率下降。

3) 電源

  • Camera init failed」十之八九是供電。改 5V/1–2A + 短線。

4) SD 卡

  • 建議 A1 / A2 等級;卡怪怪就FAT32 重新格式化

解析度 vs 延遲(概念)

解析度            端到端延遲(越少越順)
QVGA  (320×240)   ■■■□□□□  ≈120ms
VGA   (640×480)   ■■■■□□  ≈200ms
SVGA  (800×600)   ■■■■■□  ≈320ms
XGA  (1024×768)   ■■■■■■  ≈480ms

估算帶寬(概念)

解析度   平均 JPEG 大小   10 fps 估計帶寬
QVGA     ~25KB           ≈ 2.0 Mbps
VGA      ~45KB           ≈ 3.6 Mbps
SVGA     ~70KB           ≈ 5.6 Mbps
(依場景與壓縮變動)

E. PC / Pi 端實測工具:FPS × 掉幀 × 走時

Python(OpenCV)量 FPS / 掉幀率

import cv2, time, collections
url = "http://<ESP32-IP>/stream"   # 或 /:81/stream
cap = cv2.VideoCapture(url)
win = collections.deque(maxlen=100)
last = time.time(); frames=0
while True:
    ok, frame = cap.read()
    if not ok: break
    frames += 1; win.append(time.time())
    if time.time()-last >= 2:
        fps = len(win)/(win[-1]-win[0]) if len(win)>1 else 0
        print(f"FPS≈{fps:.1f}  Total:{frames}")
        last = time.time()
    cv2.imshow("ESP32-CAM", frame)
    if cv2.waitKey(1)==27: break
cap.release(); cv2.destroyAllWindows()

延遲「感覺測」小撇步
在畫面前晃動手指→看螢幕延後感;降解析度 / 亮一點 / 靠 AP,延遲很有感地下降。


F. 加碼:簡易「動態觸發」示意(輕量、先玩玩)

想要「畫面有大變化就連拍」?可以做個超輕量判斷:JPEG 檔大小變化 or 連續兩張灰階差值
下例採「JPEG 大小差」作為入門範例(不精準,但便宜好用)。

size_t last_len = 0;
void loop(){
  camera_fb_t *fb = esp_camera_fb_get();
  if(!fb) return;
  size_t L = fb->len;
  // 若大小變化超過 12% 視為有大動作(門口感應等)
  bool moving = (last_len>0) && (abs((int)L - (int)last_len) > last_len*0.12);
  last_len = L;

  if(moving){
    // 事件快照:多拍 3 張到 SD
    esp_camera_fb_return(fb); // 先釋放,再用 saveShot 真正寫入
    saveShot("event"); delay(150);
    saveShot("event"); delay(150);
    saveShot("event");
  }else{
    esp_camera_fb_return(fb);
  }
  delay(100);
}

正統的「畫面差分」要做灰階差 / 背景建模;建議放在 Pi 5(Day 5 會示範),ESP32-CAM 只負責拍拿圖。


G. 產出縮時影片(一天一支),發社群超有感

在 Pi/PC 上:

# 把當日資料夾打包成影片
cd /path/to/DCIM/2025-10-06
ffmpeg -framerate 12 -pattern_type glob -i "*.jpg" -vf "scale=1280:-1" -y timelapse_2025-10-06.mp4

自動化小心法

  • cron 每晚 23:55 自動合成;
  • 合成後把原 JPG 壓縮備份或留精選(保留策略就靠前面的 cleanOld)。

H. 今日任務清單 ✅

  • [ ] SD 卡寫入成功、檔名有時間戳
  • [ ] Timelapse 正常(連續 30 分鐘)
  • [ ] 事件快照可用(門鈴/按鈕/紅外線 → 連拍)
  • [ ] 自動清理舊檔(保留 7 天)
  • [ ] 用 Python 量 FPS,紀錄你的「穩定設定」

I. 明天預告(Day 4)

  • 網路化升級:HTTP API 更完整、MQTT、LINE Notify / Telegram 推播,
    把「看到人」→「有人通知 + 自動存證 + 雲端備份」整套打通!

上一篇
Day 2|ESP32-CAM第一次上手:刷韌體、跑 CameraWebServer
系列文
IT工具與自我學IT的過程分享23
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言